@aster-rpc/aster 0.1.2
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/dist/capabilities.d.ts +26 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +29 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +108 -0
- package/dist/client.js.map +1 -0
- package/dist/codec.d.ts +156 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +477 -0
- package/dist/codec.js.map +1 -0
- package/dist/config.d.ts +102 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +454 -0
- package/dist/config.js.map +1 -0
- package/dist/contract/identity.d.ts +115 -0
- package/dist/contract/identity.d.ts.map +1 -0
- package/dist/contract/identity.js +188 -0
- package/dist/contract/identity.js.map +1 -0
- package/dist/contract/manifest.d.ts +77 -0
- package/dist/contract/manifest.d.ts.map +1 -0
- package/dist/contract/manifest.js +127 -0
- package/dist/contract/manifest.js.map +1 -0
- package/dist/contract/publication.d.ts +71 -0
- package/dist/contract/publication.d.ts.map +1 -0
- package/dist/contract/publication.js +85 -0
- package/dist/contract/publication.js.map +1 -0
- package/dist/decorators.d.ts +139 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/decorators.js +175 -0
- package/dist/decorators.js.map +1 -0
- package/dist/dynamic.d.ts +61 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +147 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/framing.d.ts +74 -0
- package/dist/framing.d.ts.map +1 -0
- package/dist/framing.js +162 -0
- package/dist/framing.js.map +1 -0
- package/dist/health.d.ts +127 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +236 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptors/audit.d.ts +25 -0
- package/dist/interceptors/audit.d.ts.map +1 -0
- package/dist/interceptors/audit.js +46 -0
- package/dist/interceptors/audit.js.map +1 -0
- package/dist/interceptors/auth.d.ts +13 -0
- package/dist/interceptors/auth.d.ts.map +1 -0
- package/dist/interceptors/auth.js +34 -0
- package/dist/interceptors/auth.js.map +1 -0
- package/dist/interceptors/base.d.ts +74 -0
- package/dist/interceptors/base.d.ts.map +1 -0
- package/dist/interceptors/base.js +103 -0
- package/dist/interceptors/base.js.map +1 -0
- package/dist/interceptors/capability.d.ts +16 -0
- package/dist/interceptors/capability.d.ts.map +1 -0
- package/dist/interceptors/capability.js +63 -0
- package/dist/interceptors/capability.js.map +1 -0
- package/dist/interceptors/circuit-breaker.d.ts +40 -0
- package/dist/interceptors/circuit-breaker.d.ts.map +1 -0
- package/dist/interceptors/circuit-breaker.js +91 -0
- package/dist/interceptors/circuit-breaker.js.map +1 -0
- package/dist/interceptors/compression.d.ts +11 -0
- package/dist/interceptors/compression.d.ts.map +1 -0
- package/dist/interceptors/compression.js +12 -0
- package/dist/interceptors/compression.js.map +1 -0
- package/dist/interceptors/deadline.d.ts +12 -0
- package/dist/interceptors/deadline.d.ts.map +1 -0
- package/dist/interceptors/deadline.js +28 -0
- package/dist/interceptors/deadline.js.map +1 -0
- package/dist/interceptors/metrics.d.ts +43 -0
- package/dist/interceptors/metrics.d.ts.map +1 -0
- package/dist/interceptors/metrics.js +132 -0
- package/dist/interceptors/metrics.js.map +1 -0
- package/dist/interceptors/rate-limit.d.ts +24 -0
- package/dist/interceptors/rate-limit.d.ts.map +1 -0
- package/dist/interceptors/rate-limit.js +84 -0
- package/dist/interceptors/rate-limit.js.map +1 -0
- package/dist/interceptors/retry.d.ts +25 -0
- package/dist/interceptors/retry.d.ts.map +1 -0
- package/dist/interceptors/retry.js +55 -0
- package/dist/interceptors/retry.js.map +1 -0
- package/dist/limits.d.ts +77 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +137 -0
- package/dist/limits.js.map +1 -0
- package/dist/logging.d.ts +40 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +92 -0
- package/dist/logging.js.map +1 -0
- package/dist/metadata.d.ts +14 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +68 -0
- package/dist/metadata.js.map +1 -0
- package/dist/metrics.d.ts +40 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +92 -0
- package/dist/metrics.js.map +1 -0
- package/dist/peer-store.d.ts +53 -0
- package/dist/peer-store.d.ts.map +1 -0
- package/dist/peer-store.js +105 -0
- package/dist/peer-store.js.map +1 -0
- package/dist/protocol.d.ts +44 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +59 -0
- package/dist/protocol.js.map +1 -0
- package/dist/registration.d.ts +81 -0
- package/dist/registration.d.ts.map +1 -0
- package/dist/registration.js +161 -0
- package/dist/registration.js.map +1 -0
- package/dist/registry/acl.d.ts +57 -0
- package/dist/registry/acl.d.ts.map +1 -0
- package/dist/registry/acl.js +104 -0
- package/dist/registry/acl.js.map +1 -0
- package/dist/registry/client.d.ts +70 -0
- package/dist/registry/client.d.ts.map +1 -0
- package/dist/registry/client.js +115 -0
- package/dist/registry/client.js.map +1 -0
- package/dist/registry/gossip.d.ts +43 -0
- package/dist/registry/gossip.d.ts.map +1 -0
- package/dist/registry/gossip.js +102 -0
- package/dist/registry/gossip.js.map +1 -0
- package/dist/registry/keys.d.ts +25 -0
- package/dist/registry/keys.d.ts.map +1 -0
- package/dist/registry/keys.js +47 -0
- package/dist/registry/keys.js.map +1 -0
- package/dist/registry/models.d.ts +80 -0
- package/dist/registry/models.d.ts.map +1 -0
- package/dist/registry/models.js +35 -0
- package/dist/registry/models.js.map +1 -0
- package/dist/registry/publisher.d.ts +65 -0
- package/dist/registry/publisher.d.ts.map +1 -0
- package/dist/registry/publisher.js +164 -0
- package/dist/registry/publisher.js.map +1 -0
- package/dist/runtime.d.ts +267 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +1366 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +100 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +511 -0
- package/dist/server.js.map +1 -0
- package/dist/service.d.ts +72 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +98 -0
- package/dist/service.js.map +1 -0
- package/dist/session.d.ts +64 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +350 -0
- package/dist/session.js.map +1 -0
- package/dist/status.d.ts +113 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +206 -0
- package/dist/status.js.map +1 -0
- package/dist/transport/base.d.ts +46 -0
- package/dist/transport/base.d.ts.map +1 -0
- package/dist/transport/base.js +10 -0
- package/dist/transport/base.js.map +1 -0
- package/dist/transport/iroh.d.ts +45 -0
- package/dist/transport/iroh.d.ts.map +1 -0
- package/dist/transport/iroh.js +225 -0
- package/dist/transport/iroh.js.map +1 -0
- package/dist/transport/local.d.ts +48 -0
- package/dist/transport/local.d.ts.map +1 -0
- package/dist/transport/local.js +139 -0
- package/dist/transport/local.js.map +1 -0
- package/dist/trust/admission.d.ts +60 -0
- package/dist/trust/admission.d.ts.map +1 -0
- package/dist/trust/admission.js +149 -0
- package/dist/trust/admission.js.map +1 -0
- package/dist/trust/bootstrap.d.ts +109 -0
- package/dist/trust/bootstrap.d.ts.map +1 -0
- package/dist/trust/bootstrap.js +311 -0
- package/dist/trust/bootstrap.js.map +1 -0
- package/dist/trust/clock.d.ts +93 -0
- package/dist/trust/clock.d.ts.map +1 -0
- package/dist/trust/clock.js +154 -0
- package/dist/trust/clock.js.map +1 -0
- package/dist/trust/consumer.d.ts +139 -0
- package/dist/trust/consumer.d.ts.map +1 -0
- package/dist/trust/consumer.js +323 -0
- package/dist/trust/consumer.js.map +1 -0
- package/dist/trust/credentials.d.ts +98 -0
- package/dist/trust/credentials.d.ts.map +1 -0
- package/dist/trust/credentials.js +250 -0
- package/dist/trust/credentials.js.map +1 -0
- package/dist/trust/delegated.d.ts +118 -0
- package/dist/trust/delegated.d.ts.map +1 -0
- package/dist/trust/delegated.js +292 -0
- package/dist/trust/delegated.js.map +1 -0
- package/dist/trust/gossip.d.ts +146 -0
- package/dist/trust/gossip.d.ts.map +1 -0
- package/dist/trust/gossip.js +334 -0
- package/dist/trust/gossip.js.map +1 -0
- package/dist/trust/hooks.d.ts +84 -0
- package/dist/trust/hooks.d.ts.map +1 -0
- package/dist/trust/hooks.js +125 -0
- package/dist/trust/hooks.js.map +1 -0
- package/dist/trust/iid.d.ts +65 -0
- package/dist/trust/iid.d.ts.map +1 -0
- package/dist/trust/iid.js +104 -0
- package/dist/trust/iid.js.map +1 -0
- package/dist/trust/mesh.d.ts +43 -0
- package/dist/trust/mesh.d.ts.map +1 -0
- package/dist/trust/mesh.js +105 -0
- package/dist/trust/mesh.js.map +1 -0
- package/dist/trust/nonce.d.ts +39 -0
- package/dist/trust/nonce.d.ts.map +1 -0
- package/dist/trust/nonce.js +46 -0
- package/dist/trust/nonce.js.map +1 -0
- package/dist/trust/producer.d.ts +80 -0
- package/dist/trust/producer.d.ts.map +1 -0
- package/dist/trust/producer.js +151 -0
- package/dist/trust/producer.js.map +1 -0
- package/dist/trust/rcan.d.ts +29 -0
- package/dist/trust/rcan.d.ts.map +1 -0
- package/dist/trust/rcan.js +57 -0
- package/dist/trust/rcan.js.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +50 -0
- package/dist/types.js.map +1 -0
- package/dist/xlang.d.ts +26 -0
- package/dist/xlang.d.ts.map +1 -0
- package/dist/xlang.js +55 -0
- package/dist/xlang.js.map +1 -0
- package/package.json +59 -0
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level AsterServer and AsterClient wrappers.
|
|
3
|
+
*
|
|
4
|
+
* These provide one-liner setup for the common case, hiding the
|
|
5
|
+
* details of endpoint creation, admission, and transport wiring.
|
|
6
|
+
*/
|
|
7
|
+
import { ServiceRegistry } from './service.js';
|
|
8
|
+
import { LocalTransport } from './transport/local.js';
|
|
9
|
+
import { createClient } from './client.js';
|
|
10
|
+
import { configFromEnv } from './config.js';
|
|
11
|
+
import { createLogger } from './logging.js';
|
|
12
|
+
import { HealthServer } from './health.js';
|
|
13
|
+
import { DEFAULT_BACKOFF, RpcPattern, RpcScope } from './types.js';
|
|
14
|
+
import { JsonCodec } from './codec.js';
|
|
15
|
+
import { RpcServer } from './server.js';
|
|
16
|
+
import { handleConsumerAdmissionConnection, performAdmission } from './trust/consumer.js';
|
|
17
|
+
import { loadIdentity, parseSimpleToml } from './config.js';
|
|
18
|
+
import { handleDelegatedAdmissionConnection } from './trust/delegated.js';
|
|
19
|
+
import { MeshEndpointHook } from './trust/hooks.js';
|
|
20
|
+
import { PeerAttributeStore } from './peer-store.js';
|
|
21
|
+
import { CapabilityInterceptor } from './interceptors/capability.js';
|
|
22
|
+
import { canonicalXlangBytes, contractIdFromContract, fromServiceInfo } from './contract/identity.js';
|
|
23
|
+
import { writeFrame, readFrame, HEADER, TRAILER, CALL } from './framing.js';
|
|
24
|
+
import { StreamHeader, CallHeader } from './protocol.js';
|
|
25
|
+
import { StatusCode, RpcError } from './status.js';
|
|
26
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
27
|
+
const ALPN_CONSUMER_ADMISSION = 'aster.consumer_admission';
|
|
28
|
+
const ALPN_PRODUCER_ADMISSION = 'aster.producer_admission';
|
|
29
|
+
const ALPN_DELEGATED_ADMISSION = 'aster.admission';
|
|
30
|
+
const RPC_ALPN = 'aster/1';
|
|
31
|
+
// ── Errors ───────────────────────────────────────────────────────────────────
|
|
32
|
+
/**
|
|
33
|
+
* Raised when a consumer is refused by the server's admission check.
|
|
34
|
+
*
|
|
35
|
+
* The server never reveals *why* admission failed (no oracle leak), so this
|
|
36
|
+
* error enumerates the common causes as a hint to the user rather than a
|
|
37
|
+
* precise diagnosis. Its `message` is a multi-line actionable hint suitable
|
|
38
|
+
* for direct CLI output.
|
|
39
|
+
*/
|
|
40
|
+
export class AdmissionDeniedError extends Error {
|
|
41
|
+
hadCredential;
|
|
42
|
+
credentialFile;
|
|
43
|
+
ourEndpointId;
|
|
44
|
+
serverAddress;
|
|
45
|
+
constructor(opts) {
|
|
46
|
+
const shortId = opts.ourEndpointId
|
|
47
|
+
? opts.ourEndpointId.slice(0, 16) + '...'
|
|
48
|
+
: '<unknown>';
|
|
49
|
+
let message;
|
|
50
|
+
if (!opts.hadCredential) {
|
|
51
|
+
message =
|
|
52
|
+
'consumer admission denied -- this server requires a credential.\n' +
|
|
53
|
+
' - Get an enrollment credential file (.cred) from the server\'s operator.\n' +
|
|
54
|
+
' - Then retry with: --rcan <path/to/file.cred>\n' +
|
|
55
|
+
' (or set ASTER_ENROLLMENT_CREDENTIAL=<path> in the environment)';
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const credLabel = opts.credentialFile ?? '<credential>';
|
|
59
|
+
message =
|
|
60
|
+
`consumer admission denied -- the server rejected your credential.\n` +
|
|
61
|
+
` credential: ${credLabel}\n` +
|
|
62
|
+
` your node: ${shortId}\n` +
|
|
63
|
+
' Common causes:\n' +
|
|
64
|
+
' 1. The credential expired (check the \'Expires\' field on the file).\n' +
|
|
65
|
+
' 2. The credential was issued to a DIFFERENT node. Credentials are\n' +
|
|
66
|
+
' bound to a single endpoint id: if you copied this file from\n' +
|
|
67
|
+
' another machine/process, the server sees a different node id\n' +
|
|
68
|
+
' and refuses admission. Ask the operator to re-issue it for\n' +
|
|
69
|
+
` endpointId=${shortId}.\n` +
|
|
70
|
+
' 3. The server trusts a different root key than the one that signed\n' +
|
|
71
|
+
' this credential.\n' +
|
|
72
|
+
' 4. The credential\'s role/capabilities don\'t match this server\'s\n' +
|
|
73
|
+
' policy (the server may reject unknown capabilities outright).';
|
|
74
|
+
}
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'AdmissionDeniedError';
|
|
77
|
+
this.hadCredential = opts.hadCredential;
|
|
78
|
+
this.credentialFile = opts.credentialFile;
|
|
79
|
+
this.ourEndpointId = opts.ourEndpointId;
|
|
80
|
+
this.serverAddress = opts.serverAddress;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* High-level Aster RPC server.
|
|
85
|
+
*
|
|
86
|
+
* Creates an IrohNode, serves RPC over QUIC, handles consumer admission,
|
|
87
|
+
* and prints a startup banner.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const server = new AsterServer({
|
|
92
|
+
* services: [new MissionControl()],
|
|
93
|
+
* });
|
|
94
|
+
* await server.start();
|
|
95
|
+
* console.log(server.address);
|
|
96
|
+
* await server.serve();
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export class AsterServer {
|
|
100
|
+
registry;
|
|
101
|
+
config;
|
|
102
|
+
logger;
|
|
103
|
+
health;
|
|
104
|
+
_node = null;
|
|
105
|
+
_rpcServer = null;
|
|
106
|
+
_hook;
|
|
107
|
+
_peerStore;
|
|
108
|
+
_delegationPolicies = new Map();
|
|
109
|
+
_running = false;
|
|
110
|
+
_closed = false;
|
|
111
|
+
_allowAllConsumers;
|
|
112
|
+
_userInterceptors = [];
|
|
113
|
+
_signalHandlers = [];
|
|
114
|
+
_serviceSummaries = [];
|
|
115
|
+
_registryNamespace = '';
|
|
116
|
+
_servePromise = null;
|
|
117
|
+
_admissionAbort = null;
|
|
118
|
+
constructor(opts) {
|
|
119
|
+
this.config = { ...configFromEnv(), ...opts.config };
|
|
120
|
+
if (opts.identity) {
|
|
121
|
+
this.config.identityFile = opts.identity;
|
|
122
|
+
}
|
|
123
|
+
this.logger = createLogger({
|
|
124
|
+
format: this.config.logFormat,
|
|
125
|
+
level: this.config.logLevel,
|
|
126
|
+
mask: this.config.logMask,
|
|
127
|
+
});
|
|
128
|
+
this.registry = new ServiceRegistry();
|
|
129
|
+
this.health = new HealthServer({
|
|
130
|
+
port: this.config.healthPort,
|
|
131
|
+
host: this.config.healthHost,
|
|
132
|
+
});
|
|
133
|
+
this._peerStore = new PeerAttributeStore();
|
|
134
|
+
this._allowAllConsumers = opts.allowAllConsumers ?? true;
|
|
135
|
+
// In dev mode the hook must allow unenrolled peers, otherwise post-admission
|
|
136
|
+
// RPC connections would be denied (they reach Gate 0 before the peer-store
|
|
137
|
+
// entry from admission is checked, and the peer wouldn't be there for
|
|
138
|
+
// ephemeral consumers).
|
|
139
|
+
this._hook = new MeshEndpointHook(this._allowAllConsumers, this._peerStore);
|
|
140
|
+
this._userInterceptors = opts.interceptors ?? [];
|
|
141
|
+
for (const svc of opts.services) {
|
|
142
|
+
this.registry.register(svc);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Create the IrohNode and prepare for serving. Idempotent.
|
|
147
|
+
*/
|
|
148
|
+
async start() {
|
|
149
|
+
if (this._node)
|
|
150
|
+
return;
|
|
151
|
+
// Load native addon
|
|
152
|
+
const native = await loadNative();
|
|
153
|
+
if (!native) {
|
|
154
|
+
throw new Error('Aster native addon not found. Build with: cd native && npx napi build --release --platform');
|
|
155
|
+
}
|
|
156
|
+
// Initialize contract identity binding (needed for _publishContracts)
|
|
157
|
+
const { setNativeContract } = await import('./contract/identity.js');
|
|
158
|
+
setNativeContract(native);
|
|
159
|
+
// Create node with RPC + admission ALPNs.
|
|
160
|
+
//
|
|
161
|
+
// Gate 0 hooks must be enabled whenever any admission gate is active
|
|
162
|
+
// (allow_all_consumers=false). Without enable_hooks=true, the
|
|
163
|
+
// before_connect callbacks never fire and the admitted-set is
|
|
164
|
+
// unenforced — every connection is allowed regardless of admission.
|
|
165
|
+
const alpns = [
|
|
166
|
+
Buffer.from(RPC_ALPN),
|
|
167
|
+
Buffer.from(ALPN_CONSUMER_ADMISSION),
|
|
168
|
+
Buffer.from(ALPN_PRODUCER_ADMISSION),
|
|
169
|
+
Buffer.from(ALPN_DELEGATED_ADMISSION),
|
|
170
|
+
];
|
|
171
|
+
const gate0Needed = !this._allowAllConsumers;
|
|
172
|
+
const endpointConfig = gate0Needed
|
|
173
|
+
? { enableHooks: true, hookTimeoutMs: 5000 }
|
|
174
|
+
: undefined;
|
|
175
|
+
this._node = await native.IrohNode.memoryWithAlpns(alpns, endpointConfig);
|
|
176
|
+
// Build service summaries for admission response
|
|
177
|
+
// Encode the server's own node ID as the RPC channel address
|
|
178
|
+
const nodeId = this._node.nodeId();
|
|
179
|
+
const rpcAddr = Buffer.from(nodeId).toString('base64');
|
|
180
|
+
this._serviceSummaries = [];
|
|
181
|
+
for (const info of this.registry.getAllServices()) {
|
|
182
|
+
this._serviceSummaries.push({
|
|
183
|
+
name: info.name,
|
|
184
|
+
version: info.version,
|
|
185
|
+
contractId: '',
|
|
186
|
+
pattern: info.scoped ?? 'shared',
|
|
187
|
+
methods: Object.keys(info.methods),
|
|
188
|
+
channels: { rpc: rpcAddr },
|
|
189
|
+
// TS binding only speaks JSON until @apache-fory/core JS becomes
|
|
190
|
+
// XLANG-compliant. Cross-language consumers see this and pick the
|
|
191
|
+
// matching codec.
|
|
192
|
+
serializationModes: ['json'],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
// Auto-wire CapabilityInterceptor if any service declares requires=
|
|
196
|
+
const interceptors = [...this._userInterceptors];
|
|
197
|
+
let anyHasRequires = false;
|
|
198
|
+
for (const info of this.registry.getAllServices()) {
|
|
199
|
+
if (info.requires)
|
|
200
|
+
anyHasRequires = true;
|
|
201
|
+
for (const mi of info.methods.values()) {
|
|
202
|
+
if (mi.requires)
|
|
203
|
+
anyHasRequires = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const hasCapInterceptor = interceptors.some(i => i instanceof CapabilityInterceptor);
|
|
207
|
+
if ((!this._allowAllConsumers || anyHasRequires) && !hasCapInterceptor) {
|
|
208
|
+
const cap = new CapabilityInterceptor();
|
|
209
|
+
// Register requirements from service/method declarations
|
|
210
|
+
for (const info of this.registry.getAllServices()) {
|
|
211
|
+
for (const [methodName, mi] of info.methods.entries()) {
|
|
212
|
+
const req = mi.requires ?? info.requires;
|
|
213
|
+
if (req)
|
|
214
|
+
cap.setRequirement(info.name, methodName, req);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
interceptors.unshift(cap);
|
|
218
|
+
}
|
|
219
|
+
// Create the RPC server (uses JsonCodec for cross-language compat)
|
|
220
|
+
this._rpcServer = new RpcServer({
|
|
221
|
+
registry: this.registry,
|
|
222
|
+
codec: new JsonCodec(),
|
|
223
|
+
interceptors,
|
|
224
|
+
logger: this.logger,
|
|
225
|
+
peerStore: this._peerStore,
|
|
226
|
+
});
|
|
227
|
+
// Publish contracts to registry doc (non-fatal on failure)
|
|
228
|
+
await this._publishContracts();
|
|
229
|
+
await this.health.start();
|
|
230
|
+
this._running = true;
|
|
231
|
+
this._printBanner();
|
|
232
|
+
// Always log startup info (visible even when stderr is not a TTY)
|
|
233
|
+
const serviceNames = this._serviceSummaries.map(s => s.name).join(', ');
|
|
234
|
+
this.logger.info(`server starting runtime=typescript services=[${serviceNames}] mode=${this._allowAllConsumers ? 'open-gate' : 'trusted'}`);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Create a registry doc and publish each service's contract.
|
|
238
|
+
*
|
|
239
|
+
* After publication, `_registryNamespace` is set to the 64-char hex
|
|
240
|
+
* namespace ID so the admission response can return it.
|
|
241
|
+
*
|
|
242
|
+
* Non-fatal: if publication fails, the server still works — consumers
|
|
243
|
+
* just won't get rich contract metadata.
|
|
244
|
+
*/
|
|
245
|
+
async _publishContracts() {
|
|
246
|
+
try {
|
|
247
|
+
const dc = this._node.docsClient();
|
|
248
|
+
const bc = this._node.blobsClient();
|
|
249
|
+
// Step 1: Create registry doc and author
|
|
250
|
+
const registryDoc = await dc.create();
|
|
251
|
+
const authorId = await dc.createAuthor();
|
|
252
|
+
// Step 2-10: For each service, build contract and publish
|
|
253
|
+
for (const info of this.registry.getAllServices()) {
|
|
254
|
+
// Build ServiceContract from service info
|
|
255
|
+
const contract = fromServiceInfo(info);
|
|
256
|
+
const contractId = contractIdFromContract(contract);
|
|
257
|
+
// Build manifest with method field descriptors
|
|
258
|
+
const manifest = this._buildManifest(info, contractId);
|
|
259
|
+
const canonicalBytes = canonicalXlangBytes(contract);
|
|
260
|
+
// Build collection and upload to blob store
|
|
261
|
+
const { buildCollection: build } = await import('./contract/publication.js');
|
|
262
|
+
const entries = build(manifest, canonicalBytes);
|
|
263
|
+
const collectionHash = await bc.addCollection(entries.map(([name, data]) => [name, Buffer.from(data)]));
|
|
264
|
+
const ticket = bc.createCollectionTicket(collectionHash);
|
|
265
|
+
// Write ArtifactRef to registry doc
|
|
266
|
+
const { contractKey, versionKey } = await import('./registry/keys.js');
|
|
267
|
+
const artifactRef = {
|
|
268
|
+
contract_id: contractId,
|
|
269
|
+
collection_hash: collectionHash,
|
|
270
|
+
ticket,
|
|
271
|
+
published_by: authorId,
|
|
272
|
+
published_at_epoch_ms: Date.now(),
|
|
273
|
+
collection_format: 'index',
|
|
274
|
+
};
|
|
275
|
+
const encoder = new TextEncoder();
|
|
276
|
+
await registryDoc.setBytes(authorId, contractKey(contractId), Buffer.from(encoder.encode(JSON.stringify(artifactRef))));
|
|
277
|
+
// Write manifest shortcut (avoids blob download round-trip)
|
|
278
|
+
const { manifestToJson } = await import('./contract/manifest.js');
|
|
279
|
+
await registryDoc.setBytes(authorId, `manifests/${contractId}`, Buffer.from(encoder.encode(manifestToJson(manifest))));
|
|
280
|
+
// Write version pointer
|
|
281
|
+
await registryDoc.setBytes(authorId, versionKey(info.name, info.version), Buffer.from(encoder.encode(contractId)));
|
|
282
|
+
// Update service summary with contract ID
|
|
283
|
+
const summary = this._serviceSummaries.find(s => s.name === info.name);
|
|
284
|
+
if (summary)
|
|
285
|
+
summary.contractId = contractId;
|
|
286
|
+
this.logger.debug(`published contract ${contractId.slice(0, 12)} for ${info.name} v${info.version}`);
|
|
287
|
+
}
|
|
288
|
+
// Share registry doc (read-only) and store namespace ID
|
|
289
|
+
await registryDoc.shareWithAddr('read');
|
|
290
|
+
this._registryNamespace = registryDoc.docId();
|
|
291
|
+
this.logger.info(`registry doc ready — namespace=${this._registryNamespace.slice(0, 16)}… ` +
|
|
292
|
+
`services=${this.registry.getAllServices().length}`);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
// Non-fatal: server still works, but cross-language peers will see
|
|
296
|
+
// empty method tables. Log loudly so this isn't silently swallowed --
|
|
297
|
+
// every cross-language interop test depends on this code path.
|
|
298
|
+
const msg = err instanceof Error ? `${err.message}\n${err.stack ?? ''}` : String(err);
|
|
299
|
+
this.logger.error(`contract publication failed (non-fatal): cross-language clients ` +
|
|
300
|
+
`will see empty method tables. Cause: ${msg}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Build a ContractManifest from a ServiceInfo with field-level detail.
|
|
305
|
+
*/
|
|
306
|
+
_buildManifest(info, contractId) {
|
|
307
|
+
const WIRE_TYPE_KEY = Symbol.for('aster.wire_type');
|
|
308
|
+
const methods = [];
|
|
309
|
+
/** Extract a field list by instantiating a constructor and reading keys. */
|
|
310
|
+
const extractFields = (Ctor) => {
|
|
311
|
+
if (!Ctor || typeof Ctor !== 'function')
|
|
312
|
+
return [];
|
|
313
|
+
try {
|
|
314
|
+
const inst = new Ctor();
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const key of Object.keys(inst)) {
|
|
317
|
+
const val = inst[key];
|
|
318
|
+
let fieldType = 'str';
|
|
319
|
+
if (typeof val === 'number')
|
|
320
|
+
fieldType = Number.isInteger(val) ? 'int' : 'float';
|
|
321
|
+
else if (typeof val === 'boolean')
|
|
322
|
+
fieldType = 'bool';
|
|
323
|
+
else if (Array.isArray(val))
|
|
324
|
+
fieldType = 'list';
|
|
325
|
+
else if (val && typeof val === 'object')
|
|
326
|
+
fieldType = 'dict';
|
|
327
|
+
// Capture the field default so cross-language codegen can emit
|
|
328
|
+
// it. JSON-safe primitives only -- nested objects round-trip via
|
|
329
|
+
// JSON.stringify which would lose Date/Map/Set semantics.
|
|
330
|
+
let defaultValue = undefined;
|
|
331
|
+
if (val === null ||
|
|
332
|
+
typeof val === 'string' ||
|
|
333
|
+
typeof val === 'number' ||
|
|
334
|
+
typeof val === 'boolean') {
|
|
335
|
+
defaultValue = val;
|
|
336
|
+
}
|
|
337
|
+
else if (Array.isArray(val)) {
|
|
338
|
+
defaultValue = [];
|
|
339
|
+
}
|
|
340
|
+
else if (val && typeof val === 'object') {
|
|
341
|
+
defaultValue = {};
|
|
342
|
+
}
|
|
343
|
+
out.push({
|
|
344
|
+
name: key,
|
|
345
|
+
type: fieldType,
|
|
346
|
+
required: val === '' || val === 0 || val === false,
|
|
347
|
+
default: defaultValue,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const wireTagOf = (Ctor) => {
|
|
357
|
+
if (!Ctor || typeof Ctor !== 'function')
|
|
358
|
+
return undefined;
|
|
359
|
+
return Ctor[WIRE_TYPE_KEY];
|
|
360
|
+
};
|
|
361
|
+
const displayNameOf = (Ctor) => {
|
|
362
|
+
if (!Ctor || typeof Ctor !== 'function')
|
|
363
|
+
return '';
|
|
364
|
+
return Ctor.name ?? '';
|
|
365
|
+
};
|
|
366
|
+
for (const [methodName, mi] of info.methods.entries()) {
|
|
367
|
+
// RpcPattern is a string enum -- 'unary' / 'server_stream' /
|
|
368
|
+
// 'client_stream' / 'bidi_stream'. The previous integer comparison
|
|
369
|
+
// (mi.pattern === 1, etc.) silently fell through to 'unary' for
|
|
370
|
+
// every method, so the published manifest mislabeled @ServerStream
|
|
371
|
+
// and @BidiStream as unary. The server still dispatched correctly
|
|
372
|
+
// from the in-memory MethodInfo, but any client that consumed the
|
|
373
|
+
// manifest -- including the shell and `aster contract gen-client` --
|
|
374
|
+
// would call them as unary and crash on the second response frame.
|
|
375
|
+
const patternStr = mi.pattern === RpcPattern.SERVER_STREAM ? 'server_stream' :
|
|
376
|
+
mi.pattern === RpcPattern.CLIENT_STREAM ? 'client_stream' :
|
|
377
|
+
mi.pattern === RpcPattern.BIDI_STREAM ? 'bidi_stream' : 'unary';
|
|
378
|
+
const reqType = mi.requestType;
|
|
379
|
+
const respType = mi.responseType;
|
|
380
|
+
// TypeScript erases parameter types at runtime, so the only way to
|
|
381
|
+
// know what request/response classes a method takes is for the user
|
|
382
|
+
// to pass them explicitly via @Rpc({ request: T, response: U }).
|
|
383
|
+
// When a method is decorated as `@Rpc()` (or any options object that
|
|
384
|
+
// doesn't include `request:` / `response:`), the published manifest
|
|
385
|
+
// has empty fields and broken wire tags -- which silently breaks
|
|
386
|
+
// gen-client for cross-language consumers and breaks the shell's
|
|
387
|
+
// method discovery for native consumers. Warn loudly at server start
|
|
388
|
+
// so the failure mode is visible without making the decorator
|
|
389
|
+
// hard-fail (which would break unit tests of decorator metadata
|
|
390
|
+
// collection that don't actually go through the manifest publish
|
|
391
|
+
// path).
|
|
392
|
+
if (!reqType || !respType) {
|
|
393
|
+
const decoratorByPattern = {
|
|
394
|
+
unary: 'Rpc',
|
|
395
|
+
server_stream: 'ServerStream',
|
|
396
|
+
client_stream: 'ClientStream',
|
|
397
|
+
bidi_stream: 'BidiStream',
|
|
398
|
+
};
|
|
399
|
+
const decorator = decoratorByPattern[patternStr] ?? 'Rpc';
|
|
400
|
+
const missing = [];
|
|
401
|
+
if (!reqType)
|
|
402
|
+
missing.push('request');
|
|
403
|
+
if (!respType)
|
|
404
|
+
missing.push('response');
|
|
405
|
+
this.logger.warn(`${info.name}.${methodName}: @${decorator} is missing ` +
|
|
406
|
+
`${missing.join(' and ')} type(s). The published manifest will ` +
|
|
407
|
+
`have empty fields, which breaks cross-language gen-client and ` +
|
|
408
|
+
`the shell's method discovery. Pass the constructors explicitly: ` +
|
|
409
|
+
`@${decorator}({ request: SomeRequest, response: SomeResponse })`);
|
|
410
|
+
}
|
|
411
|
+
methods.push({
|
|
412
|
+
name: methodName,
|
|
413
|
+
pattern: patternStr,
|
|
414
|
+
// requestType / responseType carry the human-readable display name
|
|
415
|
+
// for the codegen (matches Python manifest layout). The wire tag
|
|
416
|
+
// travels separately so codegen can register the type with Fory.
|
|
417
|
+
requestType: displayNameOf(reqType),
|
|
418
|
+
responseType: displayNameOf(respType),
|
|
419
|
+
requestWireTag: wireTagOf(reqType),
|
|
420
|
+
responseWireTag: wireTagOf(respType),
|
|
421
|
+
timeout: mi.timeout ?? 0,
|
|
422
|
+
idempotent: mi.idempotent ?? false,
|
|
423
|
+
fields: extractFields(reqType),
|
|
424
|
+
responseFields: extractFields(respType),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
service: info.name,
|
|
429
|
+
version: info.version,
|
|
430
|
+
contractId,
|
|
431
|
+
canonicalEncoding: 'fory-xlang/0.15',
|
|
432
|
+
typeCount: 0,
|
|
433
|
+
typeHashes: [],
|
|
434
|
+
methodCount: methods.length,
|
|
435
|
+
methods,
|
|
436
|
+
// The TypeScript binding only speaks JSON on the wire — Fory JS is not
|
|
437
|
+
// yet XLANG-compliant. Cross-language consumers reading the manifest
|
|
438
|
+
// must use SerializationMode.JSON (3) for any call to this server.
|
|
439
|
+
serializationModes: ['json'],
|
|
440
|
+
scoped: (info.scoped === RpcScope.SESSION || info.scoped === 'stream') ? RpcScope.SESSION : RpcScope.SHARED,
|
|
441
|
+
deprecated: false,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Start accepting connections. Blocks until close() is called.
|
|
446
|
+
*/
|
|
447
|
+
async serve() {
|
|
448
|
+
if (!this._node || !this._rpcServer) {
|
|
449
|
+
throw new Error('AsterServer.serve() called before start()');
|
|
450
|
+
}
|
|
451
|
+
// Install signal handlers for graceful shutdown
|
|
452
|
+
this._installSignalHandlers();
|
|
453
|
+
// Enable RPC server connection handling
|
|
454
|
+
this._rpcServer.setServing(true);
|
|
455
|
+
this._peerStore.startReaper();
|
|
456
|
+
// Spawn the Gate 0 hook loop if hooks are enabled. This polls
|
|
457
|
+
// before_connect events from iroh and applies the MeshEndpointHook's
|
|
458
|
+
// allow/deny decisions. Without this, the admitted-set is never
|
|
459
|
+
// consulted and every connection passes the QUIC handshake.
|
|
460
|
+
if (this._node.hasHooks?.()) {
|
|
461
|
+
void this._runGate0().catch(e => {
|
|
462
|
+
this.logger.error('gate0 hook loop failed', { error: String(e) });
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
// Single accept loop with ALPN routing (matches Python's _accept_loop)
|
|
466
|
+
this._servePromise = this._acceptLoop();
|
|
467
|
+
try {
|
|
468
|
+
await this._servePromise;
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
if (!this._closed)
|
|
472
|
+
throw e;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Run the Gate 0 hook loop.
|
|
477
|
+
*
|
|
478
|
+
* Polls after_handshake events from the native receiver and dispatches
|
|
479
|
+
* each one through MeshEndpointHook.shouldAllow(). After-handshake fires
|
|
480
|
+
* for **all** connections (inbound and outbound) right after TLS, which
|
|
481
|
+
* is exactly when we want to enforce the admitted-set check.
|
|
482
|
+
*
|
|
483
|
+
* NOTE: We do NOT use before_connect — that fires only for *outgoing*
|
|
484
|
+
* connections in iroh, so it would miss incoming RPC connections to
|
|
485
|
+
* the server (which is what we need to gate).
|
|
486
|
+
*
|
|
487
|
+
* The native binding uses an event-id + respond-by-id API; this loop
|
|
488
|
+
* polls events directly and responds via respondAfterHandshake().
|
|
489
|
+
*/
|
|
490
|
+
async _runGate0() {
|
|
491
|
+
if (!this._node)
|
|
492
|
+
return;
|
|
493
|
+
const receiver = this._node.takeHookReceiver();
|
|
494
|
+
if (receiver == null) {
|
|
495
|
+
this.logger.warn('Gate 0: hooks enabled but no receiver available');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.logger.debug('Gate 0: hook loop started');
|
|
499
|
+
try {
|
|
500
|
+
while (true) {
|
|
501
|
+
const event = await receiver.recvAfterHandshake();
|
|
502
|
+
if (event == null) {
|
|
503
|
+
this.logger.debug('Gate 0: receiver closed');
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const alpnBytes = new Uint8Array(event.info.alpn);
|
|
507
|
+
const peerId = event.info.remoteEndpointId;
|
|
508
|
+
const allow = this._hook.shouldAllow(peerId, alpnBytes);
|
|
509
|
+
if (allow) {
|
|
510
|
+
receiver.respondHandshake(event.eventId, true, undefined, undefined);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
this.logger.info(`Gate 0 denied ${peerId.slice(0, 12)} on alpn=${new TextDecoder().decode(alpnBytes)}`);
|
|
514
|
+
receiver.respondHandshake(event.eventId, false, 403, 'not admitted');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
this.logger.error('Gate 0 hook loop error', { error: String(e) });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Stop accepting connections and close the node.
|
|
524
|
+
*/
|
|
525
|
+
async close() {
|
|
526
|
+
if (this._closed)
|
|
527
|
+
return;
|
|
528
|
+
this._closed = true;
|
|
529
|
+
this._running = false;
|
|
530
|
+
// Remove signal handlers
|
|
531
|
+
for (const cleanup of this._signalHandlers)
|
|
532
|
+
cleanup();
|
|
533
|
+
this._signalHandlers = [];
|
|
534
|
+
if (this._admissionAbort) {
|
|
535
|
+
this._admissionAbort.abort();
|
|
536
|
+
}
|
|
537
|
+
if (this._rpcServer) {
|
|
538
|
+
await this._rpcServer.close();
|
|
539
|
+
}
|
|
540
|
+
await this.health.stop();
|
|
541
|
+
if (this._node) {
|
|
542
|
+
try {
|
|
543
|
+
await this._node.close();
|
|
544
|
+
}
|
|
545
|
+
catch { /* ignore */ }
|
|
546
|
+
}
|
|
547
|
+
this.logger.info('AsterServer stopped');
|
|
548
|
+
}
|
|
549
|
+
/** The aster1... connection address for clients. */
|
|
550
|
+
get address() {
|
|
551
|
+
if (!this._node)
|
|
552
|
+
throw new Error('Server not started');
|
|
553
|
+
try {
|
|
554
|
+
const native = loadNativeSync();
|
|
555
|
+
if (native) {
|
|
556
|
+
const info = {
|
|
557
|
+
endpointId: this._node.nodeId(),
|
|
558
|
+
relayAddr: undefined,
|
|
559
|
+
directAddrs: [],
|
|
560
|
+
};
|
|
561
|
+
return native.asterTicketToString(info);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
catch { /* fallback */ }
|
|
565
|
+
return this._node.nodeId();
|
|
566
|
+
}
|
|
567
|
+
/** Hex endpoint ID of this server's node. */
|
|
568
|
+
get endpointId() {
|
|
569
|
+
if (!this._node)
|
|
570
|
+
throw new Error('Server not started');
|
|
571
|
+
return this._node.nodeId();
|
|
572
|
+
}
|
|
573
|
+
/** Whether the server is running. */
|
|
574
|
+
get running() { return this._running; }
|
|
575
|
+
/** List of services hosted by this server. */
|
|
576
|
+
get services() { return [...this._serviceSummaries]; }
|
|
577
|
+
/** Create a local in-process transport for testing. */
|
|
578
|
+
localTransport() {
|
|
579
|
+
return new LocalTransport(this.registry);
|
|
580
|
+
}
|
|
581
|
+
// ── Admission loop ──────────────────────────────────────────────────────
|
|
582
|
+
/**
|
|
583
|
+
* Single accept loop with ALPN-based routing.
|
|
584
|
+
*
|
|
585
|
+
* All aster ALPNs (RPC, consumer admission, delegated admission) are
|
|
586
|
+
* multiplexed through one channel. This loop reads the ALPN tag and
|
|
587
|
+
* dispatches to the correct handler — matching Python's `_accept_loop`.
|
|
588
|
+
*/
|
|
589
|
+
async _acceptLoop() {
|
|
590
|
+
if (!this._node)
|
|
591
|
+
return;
|
|
592
|
+
while (this._running && !this._closed) {
|
|
593
|
+
try {
|
|
594
|
+
// Accept next connection — ALPN tag is on the connection
|
|
595
|
+
const conn = await this._node.acceptAster();
|
|
596
|
+
const alpn = conn.alpn() ?? '';
|
|
597
|
+
if (alpn === RPC_ALPN) {
|
|
598
|
+
// RPC connection — dispatch to RpcServer
|
|
599
|
+
this._rpcServer.handleConnection(conn).catch(e => {
|
|
600
|
+
this.logger.error('rpc connection error', { error: String(e) });
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
else if (alpn === ALPN_CONSUMER_ADMISSION) {
|
|
604
|
+
// Consumer admission
|
|
605
|
+
this._handleAdmission(conn).catch(e => {
|
|
606
|
+
this.logger.error('admission error', { error: String(e) });
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
else if (alpn === ALPN_PRODUCER_ADMISSION) {
|
|
610
|
+
// Producer-to-producer mesh admission
|
|
611
|
+
this._handleProducerAdmission(conn).catch(e => {
|
|
612
|
+
this.logger.error('producer admission error', { error: String(e) });
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
else if (alpn === ALPN_DELEGATED_ADMISSION) {
|
|
616
|
+
// Delegated admission
|
|
617
|
+
this._handleDelegatedAdmission(conn).catch(e => {
|
|
618
|
+
this.logger.error('delegated admission error', { error: String(e) });
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
this.logger.warn(`unknown ALPN: ${alpn}`);
|
|
623
|
+
try {
|
|
624
|
+
conn.close(400, 'unknown ALPN');
|
|
625
|
+
}
|
|
626
|
+
catch { /* ignore */ }
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch (e) {
|
|
630
|
+
if (this._closed)
|
|
631
|
+
return;
|
|
632
|
+
this.logger.error('accept error', { error: String(e) });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async _handleAdmission(conn) {
|
|
637
|
+
// Adapt NAPI connection to the interface handleConsumerAdmissionConnection expects
|
|
638
|
+
const adapted = {
|
|
639
|
+
acceptBi: () => conn.acceptBi(),
|
|
640
|
+
remoteId: () => conn.remoteNodeId?.() ?? conn.remoteId?.() ?? 'unknown',
|
|
641
|
+
};
|
|
642
|
+
const opts = {
|
|
643
|
+
services: this._serviceSummaries,
|
|
644
|
+
registryNamespace: this._registryNamespace || undefined,
|
|
645
|
+
allowUnenrolled: this._allowAllConsumers,
|
|
646
|
+
peerStore: this._peerStore,
|
|
647
|
+
logger: this.logger,
|
|
648
|
+
};
|
|
649
|
+
const rootKeyHex = this._resolveRootPubkeyHex();
|
|
650
|
+
await handleConsumerAdmissionConnection(adapted, rootKeyHex, this._hook, opts);
|
|
651
|
+
}
|
|
652
|
+
// ── Producer admission ───────────────────────────────────────────────────
|
|
653
|
+
async _handleProducerAdmission(conn) {
|
|
654
|
+
// Producer admission requires root pubkey and mesh state.
|
|
655
|
+
// If not configured (open mode), reject gracefully.
|
|
656
|
+
if (!this.config.rootPubkey && !this.config.rootPubkeyFile) {
|
|
657
|
+
this.logger.warn('producer admission: no root pubkey configured, ignoring');
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const { handleProducerAdmissionConnection } = await import('./trust/bootstrap.js');
|
|
661
|
+
const rootKeyHex = this._resolveRootPubkeyHex();
|
|
662
|
+
const { MeshState } = await import('./trust/mesh.js');
|
|
663
|
+
const meshState = new MeshState();
|
|
664
|
+
await handleProducerAdmissionConnection(conn, rootKeyHex, meshState);
|
|
665
|
+
}
|
|
666
|
+
_resolveRootPubkeyHex() {
|
|
667
|
+
if (this.config.rootPubkey)
|
|
668
|
+
return Buffer.from(this.config.rootPubkey).toString('hex');
|
|
669
|
+
if (this.config.rootPubkeyFile) {
|
|
670
|
+
try {
|
|
671
|
+
const { readFileSync } = require('node:fs');
|
|
672
|
+
const expanded = this.config.rootPubkeyFile.replace(/^~/, process.env.HOME ?? '');
|
|
673
|
+
const raw = readFileSync(expanded, 'utf-8').trim();
|
|
674
|
+
if (raw.startsWith('{')) {
|
|
675
|
+
try {
|
|
676
|
+
return JSON.parse(raw).public_key;
|
|
677
|
+
}
|
|
678
|
+
catch { /* fall through */ }
|
|
679
|
+
}
|
|
680
|
+
return raw;
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return '';
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return '';
|
|
687
|
+
}
|
|
688
|
+
// ── Delegated admission ─────────────────────────────────────────────────
|
|
689
|
+
async _handleDelegatedAdmission(conn) {
|
|
690
|
+
const policy = this._delegationPolicies.values().next().value;
|
|
691
|
+
if (!policy)
|
|
692
|
+
return;
|
|
693
|
+
await handleDelegatedAdmissionConnection(conn, { policy, hook: this._hook, peerStore: this._peerStore });
|
|
694
|
+
}
|
|
695
|
+
// ── Signal handling ─────────────────────────────────────────────────────
|
|
696
|
+
_installSignalHandlers() {
|
|
697
|
+
if (typeof process === 'undefined')
|
|
698
|
+
return;
|
|
699
|
+
const shutdown = async () => {
|
|
700
|
+
this.logger.info('Server shutting down...');
|
|
701
|
+
await this.close();
|
|
702
|
+
};
|
|
703
|
+
const handler = () => { shutdown(); };
|
|
704
|
+
process.on('SIGTERM', handler);
|
|
705
|
+
process.on('SIGINT', handler);
|
|
706
|
+
this._signalHandlers.push(() => { process.off('SIGTERM', handler); }, () => { process.off('SIGINT', handler); });
|
|
707
|
+
}
|
|
708
|
+
// ── Banner ──────────────────────────────────────────────────────────────
|
|
709
|
+
_printBanner() {
|
|
710
|
+
if (typeof process !== 'undefined' && !process.stderr?.isTTY)
|
|
711
|
+
return;
|
|
712
|
+
const C = '\x1b[36m';
|
|
713
|
+
const B = '\x1b[1m';
|
|
714
|
+
const D = '\x1b[2m';
|
|
715
|
+
const G = '\x1b[32m';
|
|
716
|
+
const Y = '\x1b[33m';
|
|
717
|
+
const W = '\x1b[37m';
|
|
718
|
+
const R = '\x1b[0m';
|
|
719
|
+
const w = (s) => process.stderr.write(s);
|
|
720
|
+
w(`\n${C}${B}`);
|
|
721
|
+
w(` _ ____ _____ _____ ____\n`);
|
|
722
|
+
w(` / \\ / ___|_ _| ____| _ \\\n`);
|
|
723
|
+
w(` / _ \\ \\___ \\ | | | _| | |_) |\n`);
|
|
724
|
+
w(` / ___ \\ ___) || | | |___| _ <\n`);
|
|
725
|
+
w(` /_/ \\_\\____/ |_| |_____|_| \\_\\\n`);
|
|
726
|
+
w(`${R}\n`);
|
|
727
|
+
w(` ${D}RPC after hostnames.${R}\n\n`);
|
|
728
|
+
// Services table
|
|
729
|
+
if (this._serviceSummaries.length > 0) {
|
|
730
|
+
const maxName = Math.max(...this._serviceSummaries.map(s => s.name.length));
|
|
731
|
+
for (const s of this._serviceSummaries) {
|
|
732
|
+
const name = s.name.padEnd(maxName);
|
|
733
|
+
w(` ${G}\u25cf${R} ${B}${name}${R} ${D}v${s.version}${R}\n`);
|
|
734
|
+
}
|
|
735
|
+
w('\n');
|
|
736
|
+
}
|
|
737
|
+
// Endpoint
|
|
738
|
+
try {
|
|
739
|
+
const nodeId = this._node?.nodeId?.();
|
|
740
|
+
if (nodeId) {
|
|
741
|
+
const short = nodeId.slice(0, 16) + '\u2026';
|
|
742
|
+
w(` ${D}node id:${R} ${W}${short}${R} ${D}(this node's keypair fingerprint)${R}\n`);
|
|
743
|
+
}
|
|
744
|
+
w(` ${D}endpoint:${R} ${this.address}\n`);
|
|
745
|
+
}
|
|
746
|
+
catch { /* not started yet */ }
|
|
747
|
+
// Mode
|
|
748
|
+
const mode = this._allowAllConsumers ? `${Y}open-gate${R}` : `${G}trusted${R}`;
|
|
749
|
+
w(` ${D}mode:${R} ${mode}\n`);
|
|
750
|
+
// Log
|
|
751
|
+
const logFormat = this.config.logFormat || 'text';
|
|
752
|
+
const logLevel = this.config.logLevel || 'info';
|
|
753
|
+
w(` ${D}log:${R} ASTER_LOG_FORMAT=${W}${logFormat}${R} ASTER_LOG_LEVEL=${W}${logLevel}${R}\n`);
|
|
754
|
+
// Runtime
|
|
755
|
+
w(` ${D}runtime:${R} aster-rpc (typescript) iroh 0.97\n`);
|
|
756
|
+
// Copyright
|
|
757
|
+
w(`\n ${D}Copyright \u00a9 2026 Emrul Islam. All rights reserved.${R}\n\n`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Read the first consumer (or named) peer entry from a .aster-identity TOML file
|
|
762
|
+
* without the synthesised attributes that loadIdentity adds.
|
|
763
|
+
*/
|
|
764
|
+
function loadRawConsumerPeer(filePath, peerName) {
|
|
765
|
+
const { existsSync, readFileSync } = require('node:fs');
|
|
766
|
+
const { join } = require('node:path');
|
|
767
|
+
const path = filePath ?? join(process.cwd(), '.aster-identity');
|
|
768
|
+
if (!existsSync(path))
|
|
769
|
+
return null;
|
|
770
|
+
try {
|
|
771
|
+
const data = parseSimpleToml(readFileSync(path, 'utf-8'));
|
|
772
|
+
const peers = (data.peers ?? []);
|
|
773
|
+
if (peerName) {
|
|
774
|
+
return peers.find(p => p.name === peerName) ?? null;
|
|
775
|
+
}
|
|
776
|
+
return peers.find(p => p.role === 'consumer') ?? peers[0] ?? null;
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Build a ConsumerEnrollmentCredential from a [[peers]] entry in .aster-identity.
|
|
784
|
+
* Mirrors Python's `_credential_from_peer_entry`.
|
|
785
|
+
*/
|
|
786
|
+
function credentialFromPeerEntry(peer) {
|
|
787
|
+
return {
|
|
788
|
+
credentialType: (peer.type ?? 'policy'),
|
|
789
|
+
rootPubkey: peer.root_pubkey,
|
|
790
|
+
expiresAt: Number(peer.expires_at),
|
|
791
|
+
attributes: (peer.attributes ?? {}),
|
|
792
|
+
endpointId: peer.endpoint_id || undefined,
|
|
793
|
+
nonce: peer.nonce || undefined,
|
|
794
|
+
signature: peer.signature ?? '',
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Load a pre-signed ConsumerEnrollmentCredential from a credential file.
|
|
799
|
+
*
|
|
800
|
+
* Accepts both formats produced by `aster enroll node`:
|
|
801
|
+
*
|
|
802
|
+
* 1. **TOML** (the actual `.cred` / `.aster-identity` format produced by
|
|
803
|
+
* the CLI today): a `[node]` section with the consumer's secret key,
|
|
804
|
+
* plus one or more `[[peers]]` sections each holding a signed
|
|
805
|
+
* enrollment credential. The first consumer-role peer is used.
|
|
806
|
+
* 2. **JSON** (legacy / hand-rolled credential dumps): a flat object
|
|
807
|
+
* with credential_type / root_pubkey / expires_at / attributes /
|
|
808
|
+
* endpoint_id / nonce / signature.
|
|
809
|
+
*
|
|
810
|
+
* Format detection peeks at the first non-whitespace character: `{`
|
|
811
|
+
* means JSON, anything else means TOML. The TOML path goes through the
|
|
812
|
+
* existing identity-loader helpers so that `enrollmentCredentialFile:`
|
|
813
|
+
* and `identity:` end up doing the same thing for the same file.
|
|
814
|
+
*/
|
|
815
|
+
function loadEnrollmentCredential(filePath) {
|
|
816
|
+
const { readFileSync } = require('node:fs');
|
|
817
|
+
const { homedir } = require('node:os');
|
|
818
|
+
const expanded = filePath.startsWith('~')
|
|
819
|
+
? filePath.replace(/^~/, homedir())
|
|
820
|
+
: filePath;
|
|
821
|
+
const text = readFileSync(expanded, 'utf-8');
|
|
822
|
+
const firstChar = text.replace(/^\s+/, '').charAt(0);
|
|
823
|
+
if (firstChar === '{') {
|
|
824
|
+
// JSON path
|
|
825
|
+
const d = JSON.parse(text);
|
|
826
|
+
return {
|
|
827
|
+
credentialType: (d.credential_type ?? d.type ?? 'policy'),
|
|
828
|
+
rootPubkey: d.root_pubkey,
|
|
829
|
+
expiresAt: Number(d.expires_at),
|
|
830
|
+
attributes: d.attributes ?? {},
|
|
831
|
+
endpointId: d.endpoint_id || undefined,
|
|
832
|
+
nonce: d.nonce || undefined,
|
|
833
|
+
signature: d.signature ?? '',
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
// TOML path -- reuse the identity helpers so behaviour matches `identity:`
|
|
837
|
+
const data = parseSimpleToml(text);
|
|
838
|
+
const peers = (data.peers ?? []);
|
|
839
|
+
const consumerPeer = peers.find(p => p.role === 'consumer') ?? peers[0];
|
|
840
|
+
if (!consumerPeer) {
|
|
841
|
+
throw new Error(`loadEnrollmentCredential(${filePath}): no [[peers]] entry found in ` +
|
|
842
|
+
`the TOML credential file. Did you run \`aster enroll node --role consumer\`?`);
|
|
843
|
+
}
|
|
844
|
+
return credentialFromPeerEntry(consumerPeer);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* High-level Aster RPC client.
|
|
848
|
+
*
|
|
849
|
+
* Wraps connection setup, admission, and client stub creation.
|
|
850
|
+
* Supports reconnection with exponential backoff.
|
|
851
|
+
*/
|
|
852
|
+
export class AsterClientWrapper {
|
|
853
|
+
transport;
|
|
854
|
+
config;
|
|
855
|
+
backoff;
|
|
856
|
+
_connected = false;
|
|
857
|
+
_gossipTopic = '';
|
|
858
|
+
_address;
|
|
859
|
+
_node = null;
|
|
860
|
+
_services = [];
|
|
861
|
+
_registryNamespace = '';
|
|
862
|
+
_inlineCredential = null;
|
|
863
|
+
_enrollmentCredentialFile;
|
|
864
|
+
_identitySecretKey = null;
|
|
865
|
+
constructor(opts) {
|
|
866
|
+
this.config = { ...configFromEnv(), ...opts.config };
|
|
867
|
+
if (opts.identity) {
|
|
868
|
+
this.config.identityFile = opts.identity;
|
|
869
|
+
}
|
|
870
|
+
this._address = opts.address ?? opts.endpointAddr;
|
|
871
|
+
this.backoff = opts.retryBackoff ?? DEFAULT_BACKOFF;
|
|
872
|
+
// Load identity file (.aster-identity) if present. The first consumer-role
|
|
873
|
+
// peer entry IS the credential — mirrors Python AsterClient behaviour.
|
|
874
|
+
// The node secret_key is also pulled out so the client's QUIC endpoint id
|
|
875
|
+
// matches the endpoint_id baked into the credential.
|
|
876
|
+
//
|
|
877
|
+
// We use loadIdentity for the secret key but parse the TOML raw for the
|
|
878
|
+
// peer entry — loadIdentity synthesizes `aster.name` into attributes,
|
|
879
|
+
// which would corrupt the signed-attribute set on the credential.
|
|
880
|
+
const identity = loadIdentity(this.config.identityFile, opts.peer, 'consumer');
|
|
881
|
+
if (identity) {
|
|
882
|
+
this._identitySecretKey = identity.secretKey;
|
|
883
|
+
if (!opts.enrollmentCredentialFile) {
|
|
884
|
+
const rawPeer = loadRawConsumerPeer(this.config.identityFile, opts.peer);
|
|
885
|
+
if (rawPeer) {
|
|
886
|
+
this._inlineCredential = credentialFromPeerEntry(rawPeer);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
this._enrollmentCredentialFile =
|
|
891
|
+
opts.enrollmentCredentialFile ?? this.config.enrollmentCredentialFile;
|
|
892
|
+
// If the user only passed `enrollmentCredentialFile` (no separate
|
|
893
|
+
// `identity:`) and that file is a TOML `.aster-identity` produced by
|
|
894
|
+
// `aster enroll node`, reach into the same file for the [node]
|
|
895
|
+
// secret_key. Otherwise the QUIC endpoint id we generate at startup
|
|
896
|
+
// won't match the credential's `endpoint_id` and admission fails
|
|
897
|
+
// with no useful error.
|
|
898
|
+
//
|
|
899
|
+
// Both `enrollmentCredentialFile:` and `identity:` should do the
|
|
900
|
+
// same thing for the same TOML file -- mirrors the matching fix on
|
|
901
|
+
// the Python AsterClient.
|
|
902
|
+
if (!this._identitySecretKey
|
|
903
|
+
&& this._enrollmentCredentialFile
|
|
904
|
+
&& !identity) {
|
|
905
|
+
const credIdentity = loadIdentity(this._enrollmentCredentialFile, opts.peer, 'consumer');
|
|
906
|
+
if (credIdentity) {
|
|
907
|
+
this._identitySecretKey = credIdentity.secretKey;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (opts.transport) {
|
|
911
|
+
this.transport = opts.transport;
|
|
912
|
+
this._connected = true;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Whether the client is connected. */
|
|
916
|
+
get connected() {
|
|
917
|
+
return this._connected;
|
|
918
|
+
}
|
|
919
|
+
/** Services discovered during admission. */
|
|
920
|
+
get services() { return [...this._services]; }
|
|
921
|
+
/** Registry namespace ID for service discovery (set after admission). */
|
|
922
|
+
get registryNamespace() { return this._registryNamespace || undefined; }
|
|
923
|
+
/** Hex-encoded 32-byte gossip topic ID for the producer mesh. */
|
|
924
|
+
get gossipTopic() { return this._gossipTopic; }
|
|
925
|
+
/**
|
|
926
|
+
* Connect to the server via consumer admission, then open an RPC transport.
|
|
927
|
+
*
|
|
928
|
+
* If the client was created with a transport, this is a no-op.
|
|
929
|
+
* If created with an address, it performs the full admission handshake.
|
|
930
|
+
*/
|
|
931
|
+
async connect() {
|
|
932
|
+
if (this._connected)
|
|
933
|
+
return;
|
|
934
|
+
if (!this._address) {
|
|
935
|
+
throw new Error('AsterClient requires an address or transport. ' +
|
|
936
|
+
'Pass address="aster1..." or transport=new IrohTransport(conn).');
|
|
937
|
+
}
|
|
938
|
+
const native = await loadNative();
|
|
939
|
+
if (!native) {
|
|
940
|
+
throw new Error('Aster native addon not found.');
|
|
941
|
+
}
|
|
942
|
+
// Create an in-memory client node. When an identity file provided a
|
|
943
|
+
// secret key, pass it through so the client's endpoint id matches the
|
|
944
|
+
// credential's enrolled endpoint_id.
|
|
945
|
+
if (this._identitySecretKey) {
|
|
946
|
+
this._node = await native.IrohNode.memoryWithAlpns([Buffer.from(ALPN_CONSUMER_ADMISSION), Buffer.from(RPC_ALPN)], { secretKey: Buffer.from(this._identitySecretKey) });
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
this._node = await native.IrohNode.memory();
|
|
950
|
+
}
|
|
951
|
+
// Parse the address to get the endpoint ID and optional address hints.
|
|
952
|
+
//
|
|
953
|
+
// The ticket's `relayAddr` is a SocketAddr (STUN-discovered public IP),
|
|
954
|
+
// NOT an iroh RelayUrl. Treat it as another direct addr — iroh will use
|
|
955
|
+
// any reachable transport address it can.
|
|
956
|
+
let endpointId;
|
|
957
|
+
let allDirectAddrs = [];
|
|
958
|
+
if (this._address.startsWith('aster1')) {
|
|
959
|
+
let parsed;
|
|
960
|
+
try {
|
|
961
|
+
parsed = native.asterTicketFromString(this._address);
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
parsed = native.asterTicketDecode(Buffer.from(this._address));
|
|
965
|
+
}
|
|
966
|
+
endpointId = parsed.endpointId;
|
|
967
|
+
if (parsed.directAddrs?.length)
|
|
968
|
+
allDirectAddrs.push(...parsed.directAddrs);
|
|
969
|
+
if (parsed.relayAddr)
|
|
970
|
+
allDirectAddrs.push(parsed.relayAddr);
|
|
971
|
+
}
|
|
972
|
+
else {
|
|
973
|
+
// Treat as raw hex endpoint ID
|
|
974
|
+
endpointId = this._address;
|
|
975
|
+
}
|
|
976
|
+
const directAddrs = allDirectAddrs.length ? allDirectAddrs : undefined;
|
|
977
|
+
// Helper: connect using full address info when available, else bare endpoint ID
|
|
978
|
+
const doConnect = (alpn) => {
|
|
979
|
+
if (directAddrs) {
|
|
980
|
+
// No relay URL — iroh's connect_node_addr handles direct addresses
|
|
981
|
+
return this._node.connectNodeAddr(endpointId, alpn, directAddrs, undefined);
|
|
982
|
+
}
|
|
983
|
+
return this._node.connect(endpointId, alpn);
|
|
984
|
+
};
|
|
985
|
+
// Build credential: inline peer entry > credential file > null (open-gate).
|
|
986
|
+
let credential = this._inlineCredential;
|
|
987
|
+
let credentialFileLabel = null;
|
|
988
|
+
if (credential) {
|
|
989
|
+
credentialFileLabel = '<inline .aster-identity peer entry>';
|
|
990
|
+
}
|
|
991
|
+
else if (this._enrollmentCredentialFile) {
|
|
992
|
+
credential = loadEnrollmentCredential(this._enrollmentCredentialFile);
|
|
993
|
+
credentialFileLabel = this._enrollmentCredentialFile;
|
|
994
|
+
}
|
|
995
|
+
// Consumer admission
|
|
996
|
+
const admissionConn = await doConnect(Buffer.from(ALPN_CONSUMER_ADMISSION));
|
|
997
|
+
const admissionResponse = await performAdmission(admissionConn, credential);
|
|
998
|
+
if (!admissionResponse.admitted) {
|
|
999
|
+
let ourEndpointId = '';
|
|
1000
|
+
try {
|
|
1001
|
+
ourEndpointId = this._node?.nodeId?.() ?? '';
|
|
1002
|
+
}
|
|
1003
|
+
catch { /* ignore */ }
|
|
1004
|
+
throw new AdmissionDeniedError({
|
|
1005
|
+
hadCredential: credential != null,
|
|
1006
|
+
credentialFile: credentialFileLabel,
|
|
1007
|
+
ourEndpointId,
|
|
1008
|
+
serverAddress: this._address ?? '',
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
this._services = admissionResponse.services ?? [];
|
|
1012
|
+
this._registryNamespace = admissionResponse.registryNamespace ?? '';
|
|
1013
|
+
this._gossipTopic = admissionResponse.gossipTopic ?? '';
|
|
1014
|
+
// Open RPC connection and create transport
|
|
1015
|
+
const rpcConn = await doConnect(Buffer.from(RPC_ALPN));
|
|
1016
|
+
const { IrohTransport: IrohTx } = await import('./transport/iroh.js');
|
|
1017
|
+
this.transport = new IrohTx(rpcConn);
|
|
1018
|
+
this._connected = true;
|
|
1019
|
+
}
|
|
1020
|
+
/** Create a typed client proxy for a service class. */
|
|
1021
|
+
async client(serviceClass) {
|
|
1022
|
+
return createClient(serviceClass, this.transport);
|
|
1023
|
+
}
|
|
1024
|
+
/** Create a typed client proxy for a service class. */
|
|
1025
|
+
service(serviceClass) {
|
|
1026
|
+
return createClient(serviceClass, this.transport);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Create a dynamic proxy client for a service.
|
|
1030
|
+
*
|
|
1031
|
+
* @example
|
|
1032
|
+
* ```ts
|
|
1033
|
+
* const mc = client.proxy("MissionControl");
|
|
1034
|
+
* const result = await mc.getStatus({ agentId: "edge-1" });
|
|
1035
|
+
* console.log(result.status);
|
|
1036
|
+
* ```
|
|
1037
|
+
*/
|
|
1038
|
+
proxy(serviceName) {
|
|
1039
|
+
// Check if this is a session-scoped service from the admission summary.
|
|
1040
|
+
// If so, return a SessionProxyClient that multiplexes calls over a
|
|
1041
|
+
// single bidi stream using the session protocol.
|
|
1042
|
+
const summary = this._services.find(s => s.name === serviceName);
|
|
1043
|
+
if (summary && (summary.pattern === 'session' || summary.pattern === 'stream')) {
|
|
1044
|
+
return new SessionProxyClient(serviceName, this._node, this.transport);
|
|
1045
|
+
}
|
|
1046
|
+
return new ProxyClient(serviceName, this.transport);
|
|
1047
|
+
}
|
|
1048
|
+
/** Reconnect with exponential backoff. */
|
|
1049
|
+
async reconnect(connectFn, maxAttempts = 5) {
|
|
1050
|
+
let delay = this.backoff.initialMs;
|
|
1051
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1052
|
+
try {
|
|
1053
|
+
this.transport = await connectFn();
|
|
1054
|
+
this._connected = true;
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
catch (e) {
|
|
1058
|
+
if (attempt === maxAttempts)
|
|
1059
|
+
throw e;
|
|
1060
|
+
const jitter = delay * this.backoff.jitter * (Math.random() * 2 - 1);
|
|
1061
|
+
const waitMs = Math.min(delay + jitter, this.backoff.maxMs);
|
|
1062
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
1063
|
+
delay = Math.min(delay * this.backoff.multiplier, this.backoff.maxMs);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
/** Close the client and underlying transport. */
|
|
1068
|
+
async close() {
|
|
1069
|
+
this._connected = false;
|
|
1070
|
+
await this.transport.close();
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// ── ProxyClient ──────────────────────────────────────────────────────────────
|
|
1074
|
+
/**
|
|
1075
|
+
* Dynamic proxy client -- invokes RPC methods without local type definitions.
|
|
1076
|
+
*
|
|
1077
|
+
* Created via `AsterClientWrapper.proxy("ServiceName")`. Supports all four
|
|
1078
|
+
* RPC patterns:
|
|
1079
|
+
*
|
|
1080
|
+
* ```ts
|
|
1081
|
+
* const mc = client.proxy("MissionControl");
|
|
1082
|
+
*
|
|
1083
|
+
* // Unary
|
|
1084
|
+
* const status = await mc.getStatus({ agent_id: "edge-7" });
|
|
1085
|
+
*
|
|
1086
|
+
* // Client streaming — pass an async iterable
|
|
1087
|
+
* const result = await mc.ingestMetrics(asyncGenerator());
|
|
1088
|
+
*
|
|
1089
|
+
* // Server streaming — use .stream()
|
|
1090
|
+
* for await (const entry of mc.tailLogs.stream({ level: "info" })) { ... }
|
|
1091
|
+
*
|
|
1092
|
+
* // Bidi streaming — use .bidi()
|
|
1093
|
+
* const ch = mc.runCommand.bidi();
|
|
1094
|
+
* await ch.open();
|
|
1095
|
+
* await ch.send({ command: "ls" });
|
|
1096
|
+
* for await (const r of ch) { ... }
|
|
1097
|
+
* ```
|
|
1098
|
+
*/
|
|
1099
|
+
export class ProxyClient {
|
|
1100
|
+
serviceName;
|
|
1101
|
+
transport;
|
|
1102
|
+
constructor(serviceName, transport) {
|
|
1103
|
+
this.serviceName = serviceName;
|
|
1104
|
+
this.transport = transport;
|
|
1105
|
+
return new Proxy(this, {
|
|
1106
|
+
get(target, prop) {
|
|
1107
|
+
if (prop in target || typeof prop === 'symbol') {
|
|
1108
|
+
return target[prop];
|
|
1109
|
+
}
|
|
1110
|
+
return _proxyMethod(target.serviceName, prop, target.transport);
|
|
1111
|
+
},
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
/** A bound proxy method supporting all RPC patterns. */
|
|
1116
|
+
function _proxyMethod(serviceName, methodName, transport) {
|
|
1117
|
+
// The callable: detects async iterables for client streaming, else unary
|
|
1118
|
+
const fn = async (payload) => {
|
|
1119
|
+
// Detect client streaming: payload is an async iterable (but not a plain object)
|
|
1120
|
+
if (payload != null && typeof payload === 'object' && Symbol.asyncIterator in payload) {
|
|
1121
|
+
return transport.clientStream(serviceName, methodName, payload);
|
|
1122
|
+
}
|
|
1123
|
+
// Default: unary. If the user accidentally calls a server-streaming
|
|
1124
|
+
// method via `await proxy.method(...)`, the underlying transport will
|
|
1125
|
+
// see a second response frame and throw with "multiple response
|
|
1126
|
+
// frames". Catch that and re-raise with an actionable hint pointing
|
|
1127
|
+
// at `proxy.method.stream(...)` / `.bidi()`.
|
|
1128
|
+
try {
|
|
1129
|
+
return await transport.unary(serviceName, methodName, payload ?? {});
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1133
|
+
if (msg.includes('multiple response frames')) {
|
|
1134
|
+
throw new Error(`'${serviceName}.${methodName}' is a streaming RPC and cannot be ` +
|
|
1135
|
+
`called as a unary 'await proxy.${methodName}(...)'.\n` +
|
|
1136
|
+
` - For server-streaming methods, iterate the result of ` +
|
|
1137
|
+
`'proxy.${methodName}.stream(...)':\n` +
|
|
1138
|
+
` for await (const item of proxy.${methodName}.stream({...})) { ... }\n` +
|
|
1139
|
+
` - For bidi-streaming methods, use 'proxy.${methodName}.bidi()'.`);
|
|
1140
|
+
}
|
|
1141
|
+
throw err;
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
// .stream() — server streaming
|
|
1145
|
+
fn.stream = (payload) => {
|
|
1146
|
+
return transport.serverStream(serviceName, methodName, payload ?? {});
|
|
1147
|
+
};
|
|
1148
|
+
// .bidi() — bidirectional streaming. Returns a lazy wrapper so callers
|
|
1149
|
+
// can do `const ch = m.bidi(); await ch.open(); await ch.send(...)` —
|
|
1150
|
+
// mirrors Python's _ProxyBidiChannel behaviour. Opening eagerly here
|
|
1151
|
+
// would force every call site to handle the connect failure synchronously.
|
|
1152
|
+
fn.bidi = () => {
|
|
1153
|
+
return new ProxyBidiChannel(serviceName, methodName, transport);
|
|
1154
|
+
};
|
|
1155
|
+
return fn;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Lazy bidi-stream wrapper used by ProxyClient. Opens the underlying
|
|
1159
|
+
* transport channel on first .open() / .send() / iteration.
|
|
1160
|
+
*/
|
|
1161
|
+
export class ProxyBidiChannel {
|
|
1162
|
+
serviceName;
|
|
1163
|
+
methodName;
|
|
1164
|
+
transport;
|
|
1165
|
+
_channel = null;
|
|
1166
|
+
constructor(serviceName, methodName, transport) {
|
|
1167
|
+
this.serviceName = serviceName;
|
|
1168
|
+
this.methodName = methodName;
|
|
1169
|
+
this.transport = transport;
|
|
1170
|
+
}
|
|
1171
|
+
async open() {
|
|
1172
|
+
if (this._channel)
|
|
1173
|
+
return;
|
|
1174
|
+
this._channel = this.transport.bidiStream(this.serviceName, this.methodName);
|
|
1175
|
+
}
|
|
1176
|
+
async send(payload) {
|
|
1177
|
+
if (!this._channel)
|
|
1178
|
+
await this.open();
|
|
1179
|
+
await this._channel.send(payload);
|
|
1180
|
+
}
|
|
1181
|
+
async close() {
|
|
1182
|
+
if (this._channel)
|
|
1183
|
+
await this._channel.close();
|
|
1184
|
+
}
|
|
1185
|
+
[Symbol.asyncIterator]() {
|
|
1186
|
+
if (!this._channel) {
|
|
1187
|
+
// Open on the fly so `for await` after `await ch.send()` (which
|
|
1188
|
+
// already opened) and `for await` without an explicit open both work.
|
|
1189
|
+
this._channel = this.transport.bidiStream(this.serviceName, this.methodName);
|
|
1190
|
+
}
|
|
1191
|
+
return this._channel[Symbol.asyncIterator]();
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
// ── SessionProxyClient ──────────────────────────────────────────────────────
|
|
1195
|
+
/**
|
|
1196
|
+
* Dynamic proxy client for session-scoped services. Opens a single bidi QUIC
|
|
1197
|
+
* stream with the session protocol (StreamHeader method="") and multiplexes
|
|
1198
|
+
* calls via CALL frames. Created automatically by AsterClientWrapper.proxy()
|
|
1199
|
+
* when the service summary indicates scoped='session'.
|
|
1200
|
+
*/
|
|
1201
|
+
class SessionProxyClient {
|
|
1202
|
+
serviceName;
|
|
1203
|
+
_transport;
|
|
1204
|
+
_send = null;
|
|
1205
|
+
_recv = null;
|
|
1206
|
+
_codec = new JsonCodec();
|
|
1207
|
+
_opening = null;
|
|
1208
|
+
constructor(serviceName, _node, _transport) {
|
|
1209
|
+
this.serviceName = serviceName;
|
|
1210
|
+
this._transport = _transport;
|
|
1211
|
+
return new Proxy(this, {
|
|
1212
|
+
get(target, prop) {
|
|
1213
|
+
if (prop in target || typeof prop === 'symbol') {
|
|
1214
|
+
return target[prop];
|
|
1215
|
+
}
|
|
1216
|
+
return target._sessionMethod(prop);
|
|
1217
|
+
},
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
_sessionMethod(methodName) {
|
|
1221
|
+
const self = this;
|
|
1222
|
+
const fn = async (payload) => {
|
|
1223
|
+
await self._ensureOpen();
|
|
1224
|
+
return self._callUnary(methodName, payload ?? {});
|
|
1225
|
+
};
|
|
1226
|
+
fn.stream = (_payload) => {
|
|
1227
|
+
throw new RpcError(StatusCode.UNIMPLEMENTED, 'session proxy server_stream not yet implemented');
|
|
1228
|
+
};
|
|
1229
|
+
fn.bidi = () => {
|
|
1230
|
+
throw new RpcError(StatusCode.UNIMPLEMENTED, 'session proxy bidi not yet implemented');
|
|
1231
|
+
};
|
|
1232
|
+
return fn;
|
|
1233
|
+
}
|
|
1234
|
+
async _ensureOpen() {
|
|
1235
|
+
if (this._send)
|
|
1236
|
+
return;
|
|
1237
|
+
if (this._opening)
|
|
1238
|
+
return this._opening;
|
|
1239
|
+
this._opening = this._open();
|
|
1240
|
+
return this._opening;
|
|
1241
|
+
}
|
|
1242
|
+
async _open() {
|
|
1243
|
+
// The IrohTransport wraps the connection. We need the raw connection
|
|
1244
|
+
// to open a bidi stream. Reach through the transport to get it.
|
|
1245
|
+
const conn = this._transport.conn ?? this._transport._conn;
|
|
1246
|
+
if (!conn)
|
|
1247
|
+
throw new RpcError(StatusCode.UNAVAILABLE, 'no QUIC connection for session');
|
|
1248
|
+
const bi = await conn.openBi();
|
|
1249
|
+
this._send = bi.takeSend();
|
|
1250
|
+
this._recv = bi.takeRecv();
|
|
1251
|
+
// Send the session StreamHeader (method="" signals session mode)
|
|
1252
|
+
const header = new StreamHeader({
|
|
1253
|
+
service: this.serviceName,
|
|
1254
|
+
method: '',
|
|
1255
|
+
version: 1,
|
|
1256
|
+
callId: crypto.randomUUID(),
|
|
1257
|
+
serializationMode: 3, // JSON
|
|
1258
|
+
});
|
|
1259
|
+
await writeFrame(this._send, this._codec.encode(header), HEADER);
|
|
1260
|
+
}
|
|
1261
|
+
async _callUnary(method, request) {
|
|
1262
|
+
// Send CALL frame with CallHeader
|
|
1263
|
+
const callHeader = new CallHeader({
|
|
1264
|
+
method,
|
|
1265
|
+
callId: crypto.randomUUID(),
|
|
1266
|
+
});
|
|
1267
|
+
await writeFrame(this._send, this._codec.encode(callHeader), CALL);
|
|
1268
|
+
// Send request payload
|
|
1269
|
+
await writeFrame(this._send, this._codec.encode(request), 0);
|
|
1270
|
+
// Read response
|
|
1271
|
+
const respFrame = await readFrame(this._recv, 0);
|
|
1272
|
+
if (!respFrame)
|
|
1273
|
+
throw new RpcError(StatusCode.UNAVAILABLE, 'session stream ended');
|
|
1274
|
+
const [respPayload, respFlags] = respFrame;
|
|
1275
|
+
if (respFlags & TRAILER) {
|
|
1276
|
+
const status = this._codec.decode(respPayload);
|
|
1277
|
+
throw RpcError.fromStatus(status.code, status.message);
|
|
1278
|
+
}
|
|
1279
|
+
// Spec: session unary has no success trailer. The response frame is
|
|
1280
|
+
// the complete response. Both Python and TS servers follow this rule.
|
|
1281
|
+
return this._codec.decode(respPayload);
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Close this session, releasing the underlying bidi stream.
|
|
1285
|
+
*
|
|
1286
|
+
* Must be a real method (not synthesised by the JS Proxy) so that
|
|
1287
|
+
* `proxy.close()` calls this implementation instead of routing
|
|
1288
|
+
* "close" through the session protocol as a remote method name.
|
|
1289
|
+
*/
|
|
1290
|
+
async close() {
|
|
1291
|
+
const send = this._send;
|
|
1292
|
+
this._send = null;
|
|
1293
|
+
this._recv = null;
|
|
1294
|
+
if (send && typeof send.finish === 'function') {
|
|
1295
|
+
try {
|
|
1296
|
+
await send.finish();
|
|
1297
|
+
}
|
|
1298
|
+
catch {
|
|
1299
|
+
// Already closed / broken pipe -- best effort.
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
// ── Native addon loader ──────────────────────────────────────────────────────
|
|
1305
|
+
let _native = null;
|
|
1306
|
+
function loadNativeSync() {
|
|
1307
|
+
if (_native)
|
|
1308
|
+
return _native;
|
|
1309
|
+
const { resolve } = require('node:path');
|
|
1310
|
+
const { existsSync } = require('node:fs');
|
|
1311
|
+
const platforms = [
|
|
1312
|
+
'aster-transport.darwin-arm64.node',
|
|
1313
|
+
'aster-transport.darwin-x64.node',
|
|
1314
|
+
'aster-transport.linux-x64-gnu.node',
|
|
1315
|
+
'aster-transport.linux-arm64-gnu.node',
|
|
1316
|
+
'aster-transport.win32-x64-msvc.node',
|
|
1317
|
+
];
|
|
1318
|
+
// 1. Try the workspace package
|
|
1319
|
+
try {
|
|
1320
|
+
_native = require('@aster-rpc/transport');
|
|
1321
|
+
return _native;
|
|
1322
|
+
}
|
|
1323
|
+
catch { /* next */ }
|
|
1324
|
+
// 2. Try ASTER_NATIVE_PATH env var
|
|
1325
|
+
const envPath = process.env.ASTER_NATIVE_PATH;
|
|
1326
|
+
if (envPath) {
|
|
1327
|
+
for (const name of platforms) {
|
|
1328
|
+
const full = resolve(envPath, name);
|
|
1329
|
+
if (existsSync(full)) {
|
|
1330
|
+
_native = require(full);
|
|
1331
|
+
return _native;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
// 3. Try common workspace layouts relative to this file
|
|
1336
|
+
const searchDirs = [];
|
|
1337
|
+
try {
|
|
1338
|
+
const { dirname } = require('node:path');
|
|
1339
|
+
// When loaded from packages/aster/src/
|
|
1340
|
+
const thisDir = typeof __dirname !== 'undefined'
|
|
1341
|
+
? __dirname
|
|
1342
|
+
: dirname(new URL(import.meta.url).pathname);
|
|
1343
|
+
searchDirs.push(resolve(thisDir, '../../../native')); // packages/aster/src -> native
|
|
1344
|
+
searchDirs.push(resolve(thisDir, '../../native')); // packages/aster -> native
|
|
1345
|
+
searchDirs.push(resolve(process.cwd(), 'bindings/typescript/native')); // repo root
|
|
1346
|
+
searchDirs.push(resolve(process.cwd(), 'node_modules/@aster-rpc/transport')); // linked
|
|
1347
|
+
}
|
|
1348
|
+
catch { /* ignore */ }
|
|
1349
|
+
for (const dir of searchDirs) {
|
|
1350
|
+
for (const name of platforms) {
|
|
1351
|
+
const full = resolve(dir, name);
|
|
1352
|
+
try {
|
|
1353
|
+
if (existsSync(full)) {
|
|
1354
|
+
_native = require(full);
|
|
1355
|
+
return _native;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
catch { /* next */ }
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return null;
|
|
1362
|
+
}
|
|
1363
|
+
async function loadNative() {
|
|
1364
|
+
return loadNativeSync();
|
|
1365
|
+
}
|
|
1366
|
+
//# sourceMappingURL=runtime.js.map
|