@procedures/peer 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/peer/index.ts CHANGED
@@ -1,49 +1,36 @@
1
1
  /**
2
2
  * Peer Module
3
3
  *
4
- * Transport-agnostic peer implementation for bidirectional RPC.
4
+ * A peer is a procedures host with a service of its own (CLI-7), for a process that has no service
5
+ * of its own: a test, or a host that a procedure starts (`server.create`). `createPeer()` builds it
6
+ * with `createProcedureHost()`: each HTTP or WebSocket transport of the options is a face on a
7
+ * listener of the host, and the listener is an `httpTransport` of `@mark1russell7/service/http`.
8
+ *
9
+ * - `start()` declares the parts in a service and starts `run()` of `@mark1russell7/service`, on
10
+ * a process port that installs no signal handler and never ends the process.
11
+ * - `stop()` asks the lifecycle for its shutdown, and waits for the end of its stages.
12
+ *
13
+ * A process that has a service of its own declares the parts of `createProcedureHost()` with
14
+ * `host.declare(svc)`, in place of a peer (decision D2). The peer has no listener, URL strategy,
15
+ * `/health` route or token check of its own.
5
16
  */
6
17
 
18
+ import { daemon, embeddedProcess, service, type Lifecycle, type Profile } from "@mark1russell7/service";
7
19
  import {
8
- ProcedureServer,
9
- HttpServerTransport,
10
- WebSocketServerTransport,
11
- type ProcedureRegistry,
12
- PROCEDURE_REGISTRY,
13
- rpcServerUrlStrategy,
14
- defaultServerUrlStrategy,
15
- defaultUrlPattern,
16
- procedureServerOptions,
17
- hostProcedures,
18
- metricsInterceptor,
19
- Client,
20
+ hostPolicy,
21
+ seconds,
20
22
  type BrowserGuardOptions,
23
+ type Client,
21
24
  type HostPolicy,
22
- type Method,
25
+ type ProcedurePath,
26
+ type ProcedureRegistry,
27
+ type ProcedureServer,
28
+ type StopOptions,
23
29
  } from "@procedures/client";
24
- import type { Request } from "express";
25
- import type { ProcedurePath, StopOptions } from "@procedures/client";
30
+ import { defaultServerUrlStrategy, rpcServerUrlStrategy, type ProcedureHandlerOptions, type UpgradeHandlerOptions } from "@procedures/client/node";
26
31
  import type { HttpTransportConfig, TransportConfig, TransportType, WebSocketTransportConfig } from "../types.js";
27
- import { bearerToken, hostAllowed, originAllowed, tokenMatches } from "./security.js";
28
-
29
- /**
30
- * URL strategy that matches the client's defaultUrlPattern
31
- * Maps: POST /api/server/status → { service: "server", operation: "status" }
32
- */
33
- function patternUrlStrategy(req: Request): Method | null {
34
- const basePath = "/api";
35
- let path = req.path;
36
-
37
- // Remove base path prefix if present
38
- if (path.startsWith(basePath)) {
39
- path = path.slice(basePath.length);
40
- }
41
-
42
- // Use the client's defaultUrlPattern parser
43
- // Cast method to expected type (parser doesn't actually use it for this pattern)
44
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
- return defaultUrlPattern.parse(path, req.method.toUpperCase() as any);
46
- }
32
+ import { createProcedureHost, httpFace, LOCAL_LISTENER_KNOBS, wsFace, type HostFaceFactory, type HostListener, type ProcedureHost, type ProcedureHostOptions } from "../host/index.js";
33
+ import { originAllowed } from "./security.js";
47
34
 
48
35
  // =============================================================================
49
36
  // Types
@@ -67,16 +54,13 @@ export interface PeerOptions {
67
54
  metrics?: boolean | undefined;
68
55
  /**
69
56
  * The policy of the host (`hostPolicy()` of the core, roadmap 2.3): the expose rule, the call
70
- * rule, the interceptors (a time limit), the token of each network transport that has none of
71
- * its own, and the browser checks. `expose` above, when given, wins over the policy's.
57
+ * rule, the interceptors (a time limit), the token and the browser checks. `expose` above, when
58
+ * given, wins over the policy's. A peer has one token: the token of its transports, or else the
59
+ * token of the policy.
72
60
  */
73
61
  policy?: HostPolicy | undefined;
74
- }
75
-
76
- /** The port that a listening server got, or undefined. With port 0 the system gives a free port. */
77
- function listeningPort(server: import("node:http").Server): number | undefined {
78
- const address = server.address();
79
- return typeof address === "object" && address !== null ? address.port : undefined;
62
+ /** The profile of the service of the peer. The default is `PEER_PROFILE`. */
63
+ profile?: Profile | undefined;
80
64
  }
81
65
 
82
66
  export interface PeerEndpoint {
@@ -87,15 +71,21 @@ export interface PeerEndpoint {
87
71
  export interface Peer {
88
72
  /** Peer ID */
89
73
  id: string;
90
- /** Start all transports */
74
+ /** This method starts the parts of the host. A peer starts one time. */
91
75
  start(): Promise<void>;
92
- /** Stop all transports */
93
- /** Stop the peer. With `grace`, the calls in flight have that time to end (roadmap R14). */
76
+ /**
77
+ * This method asks the lifecycle of the peer for its shutdown, and waits for the end of its stages.
78
+ *
79
+ * @param options - Deprecated: the grace has no effect. The drain of the profile of the peer gives
80
+ * the calls in flight their time (CLI-7).
81
+ */
94
82
  stop(options?: StopOptions): Promise<void>;
95
83
  /** Get active endpoints */
96
84
  getEndpoints(): PeerEndpoint[];
97
85
  /** Get underlying server */
98
86
  getServer(): ProcedureServer;
87
+ /** The procedures host of the peer: its parts, its faces and its info (CLI-7). */
88
+ getHost(): ProcedureHost;
99
89
  /** The ids of the WebSocket connections of the clients (roadmap R24). */
100
90
  connections(): string[];
101
91
  /**
@@ -109,277 +99,166 @@ export interface Peer {
109
99
  // Implementation
110
100
  // =============================================================================
111
101
 
112
- class PeerImpl implements Peer {
113
- readonly id: string;
114
- private server: ProcedureServer;
115
- private transports: TransportConfig[];
116
- private endpoints: PeerEndpoint[] = [];
117
- private started = false;
118
- /** The WebSocket transports of the peer: the calls to its clients go over them (R24) */
119
- private wsTransports: WebSocketServerTransport[] = [];
120
- /** The HTTP servers that the peer made for its WebSocket transports: `stop()` closes them */
121
- private ownedServers: Array<import("node:http").Server> = [];
122
- private registry: ProcedureRegistry;
123
- private startTime: number = Date.now();
124
-
125
- /** The browser checks of the policy (an origin rule here replaces the peer's own rule). */
126
- private browser: BrowserGuardOptions;
127
-
128
- constructor(options: PeerOptions) {
129
- this.id = options.id;
130
- const policy = options.policy;
131
- // The policy's token goes to each network transport that has none of its own
132
- this.transports = options.transports.map((transport) =>
133
- transport.type === "local" || policy?.token === undefined || transport.token !== undefined
134
- ? transport
135
- : { ...transport, token: policy.token }
136
- );
137
- this.registry = options.registry ?? PROCEDURE_REGISTRY;
138
- this.browser = policy?.browser ?? {};
139
-
140
- // Create procedure server. The metrics interceptor counts each call for host.metrics (R19).
141
- const metrics = options.metrics === false ? undefined : metricsInterceptor();
142
- const serverOptions = policy ? procedureServerOptions(policy, this.registry) : {};
143
- const interceptors = [...(serverOptions.interceptors ?? []), ...(metrics ? [metrics.interceptor] : [])];
144
- this.server = new ProcedureServer({
145
- ...serverOptions,
146
- ...(interceptors.length > 0 ? { interceptors } : {}),
147
- autoRegister: options.autoRegister ?? true,
148
- registry: this.registry,
149
- ...(options.expose ? { expose: options.expose } : {}),
150
- });
151
- // host.health, host.info and host.metrics: pinned on the server, not in the registry (R19)
152
- this.server.registerProcedures(
153
- hostProcedures({
154
- id: this.id,
155
- startedAt: this.startTime,
156
- server: this.server,
157
- endpoints: () => this.getEndpoints(),
158
- ...(metrics ? { metrics } : {}),
159
- })
160
- );
102
+ /**
103
+ * The policy of a peer, with its one token. A transport token and a policy token must be the same:
104
+ * the one server examines one token for each face.
105
+ */
106
+ function peerPolicy(options: PeerOptions): HostPolicy {
107
+ const own = new Set(options.transports.flatMap((transport) => (transport.type !== "local" && transport.token !== undefined ? [transport.token] : [])));
108
+ const policyToken = options.policy?.token;
109
+ if (own.size > 1 || (own.size === 1 && policyToken !== undefined && !own.has(policyToken))) {
110
+ throw new Error("A peer has one token: give the same token to each transport, or give it only in the policy");
161
111
  }
112
+ const token = [...own][0] ?? policyToken;
113
+ if (options.policy) return token === policyToken ? options.policy : { ...options.policy, token };
114
+ return hostPolicy(token !== undefined ? { token } : {});
115
+ }
162
116
 
163
- async start(): Promise<void> {
164
- if (this.started) return;
165
-
166
- try {
167
- for (const transport of this.transports) {
168
- await this.startTransport(transport);
169
- }
170
- } catch (error) {
171
- // Stop the transports that started, so a failed start leaves no port open
172
- await this.server.stop().catch(() => {});
173
- this.endpoints = [];
174
- throw error;
175
- }
117
+ /** The options of the HTTP face of a transport: the rules of the peer before CLI-7, on `procedureHandler`. */
118
+ function httpOptionsOf(config: HttpTransportConfig, browser: BrowserGuardOptions): ProcedureHandlerOptions {
119
+ return {
120
+ basePath: config.basePath ?? "/api",
121
+ // Off by default: before, every peer sent Access-Control-Allow-Origin: * (deep dive CLI-2)
122
+ cors: config.cors ?? false,
123
+ ...(config.urlStrategy === "rpc" ? { urlStrategy: rpcServerUrlStrategy } : config.urlStrategy === "rest" ? { urlStrategy: defaultServerUrlStrategy } : {}),
124
+ // The browser checks follow the rule of the peer: loopback origins and the listed ones
125
+ allowedOrigins: browser.allowedOrigins ?? ((origin: string) => originAllowed(origin, config.corsOrigins)),
126
+ ...(browser.allowedHosts !== undefined ? { allowedHosts: browser.allowedHosts } : {}),
127
+ // The RESTful strategy calls by GET
128
+ allowGet: browser.allowGet ?? config.urlStrategy === "rest",
129
+ ...(config.bodyLimit !== undefined ? { bodyLimit: config.bodyLimit } : {}),
130
+ };
131
+ }
176
132
 
177
- this.started = true;
178
- }
133
+ /** The options of the WebSocket face of a transport. */
134
+ function wsOptionsOf(config: WebSocketTransportConfig, browser: BrowserGuardOptions): UpgradeHandlerOptions {
135
+ return {
136
+ path: config.path ?? "/ws",
137
+ allowedOrigins: browser.allowedOrigins ?? ((origin: string) => originAllowed(origin, config.origins)),
138
+ ...(browser.allowedHosts !== undefined ? { allowedHosts: browser.allowedHosts } : {}),
139
+ };
140
+ }
179
141
 
180
- private async startTransport(config: TransportConfig): Promise<void> {
181
- switch (config.type) {
182
- case "http":
183
- await this.startHttpTransport(config);
184
- break;
185
- case "websocket":
186
- await this.startWebSocketTransport(config);
187
- break;
188
- case "local":
189
- // Local transport doesn't need network setup
190
- this.endpoints.push({
191
- type: "local",
192
- address: "local://in-process",
193
- });
194
- break;
195
- }
142
+ /**
143
+ * The faces and the listeners of the transports. Two transports on one address and port share one
144
+ * listener. Loopback is the default address, so the whole registry (`shell.run`, `fs.write`) is
145
+ * not served on each interface (BUGS-2026-07 H19). Give `host: "0.0.0.0"` on purpose for the LAN.
146
+ */
147
+ function facesOf(transports: readonly TransportConfig[], browser: BrowserGuardOptions): { faces: Record<string, HostFaceFactory>; listen: HostListener[] } {
148
+ const faces: Record<string, HostFaceFactory> = {};
149
+ const listeners: Array<{ name: string; port: number; host: string; faces: string[] }> = [];
150
+ const counts = { http: 0, websocket: 0 };
151
+ for (const transport of transports) {
152
+ if (transport.type === "local") continue;
153
+ const kind = transport.type === "http" ? "http" : "ws";
154
+ const index = counts[transport.type]++;
155
+ const faceName = index === 0 ? kind : `${kind}${index + 1}`;
156
+ faces[faceName] =
157
+ transport.type === "http" ? httpFace(httpOptionsOf(transport, browser)) : wsFace(wsOptionsOf(transport, browser));
158
+ const port = transport.port ?? (transport.type === "http" ? 3000 : 3001);
159
+ const host = transport.host ?? "127.0.0.1";
160
+ const shared = port === 0 ? undefined : listeners.find((listener) => listener.port === port && listener.host === host);
161
+ if (shared) shared.faces.push(faceName);
162
+ else listeners.push({ name: faceName, port, host, faces: [faceName] });
196
163
  }
164
+ return { faces, listen: listeners.map((listener) => ({ ...listener, knobs: LOCAL_LISTENER_KNOBS })) };
165
+ }
197
166
 
198
- private async startHttpTransport(config: HttpTransportConfig): Promise<void> {
199
- // Dynamic import to avoid bundling express if not used
200
- const express = (await import("express")).default;
201
-
202
- const app = express();
203
- // No body parser here: the transport reads the body itself, with bodyLimit, and accepts each
204
- // JSON value (roadmap R5). Before, the strict parser of express refused a bare value (a
205
- // number, a string, null) with 400, and its limit of 100 kB replaced bodyLimit.
206
-
207
- const port = config.port ?? 3000;
208
- // Loopback by default so the full procedure registry (incl. shell.run / fs.write) is not
209
- // served unauthenticated on all interfaces. Pass host: "0.0.0.0" to expose on the LAN
210
- // deliberately. See documentation/BUGS-2026-07.md (H19).
211
- const host = config.host ?? "127.0.0.1";
212
- const basePath = config.basePath ?? "/api";
213
-
214
- // Host, origin and token checks (deep dive TRN-1, CLI-1, CLI-2). The health endpoints need no
215
- // token: a client uses them to check that a lockfile's server is alive.
216
- app.use((req, res, next) => {
217
- if (!hostAllowed(host, req.headers.host) || !originAllowed(req.headers.origin, config.corsOrigins)) {
218
- res.status(403).json({ error: "Forbidden: the host or the origin is not allowed", code: "FORBIDDEN" });
219
- return;
220
- }
221
- const isHealth = req.path === "/health" || req.path === `${basePath}/health`;
222
- if (!isHealth && req.method !== "OPTIONS" && !tokenMatches(config.token, bearerToken(req.headers.authorization))) {
223
- res.status(401).json({ error: "Unauthorized: this server requires its token", code: "UNAUTHORIZED" });
224
- return;
225
- }
226
- next();
227
- });
228
-
229
- // Health endpoint for container orchestration
230
- app.get(`${basePath}/health`, (_req, res) => {
231
- const procedureCount = this.registry.getAll().filter((p) => p.handler).length;
232
- res.json({
233
- status: "ok",
234
- uptime: Date.now() - this.startTime,
235
- procedures: procedureCount,
236
- peerId: this.id,
237
- });
238
- });
239
-
240
- // Also add at root /health for convenience
241
- app.get("/health", (_req, res) => {
242
- const procedureCount = this.registry.getAll().filter((p) => p.handler).length;
243
- res.json({
244
- status: "ok",
245
- uptime: Date.now() - this.startTime,
246
- procedures: procedureCount,
247
- peerId: this.id,
248
- });
249
- });
167
+ /**
168
+ * This function gives the options of a procedures host for the options of a peer: each HTTP or
169
+ * WebSocket transport is a face on a listener with `LOCAL_LISTENER_KNOBS`, and the peer has one
170
+ * token. `createPeer()` uses it, and so does `runProcedureHost()` of `@procedures/server` (CLI-8).
171
+ */
172
+ export function peerHostOptions(options: PeerOptions, name: string = "peer"): ProcedureHostOptions {
173
+ const policy = peerPolicy(options);
174
+ const { faces, listen } = facesOf(options.transports, policy.browser);
175
+ const local = options.transports.some((transport) => transport.type === "local");
176
+ return {
177
+ name,
178
+ id: options.id,
179
+ policy,
180
+ faces,
181
+ listen,
182
+ autoRegister: options.autoRegister ?? true,
183
+ ...(options.registry ? { registry: options.registry } : {}),
184
+ ...(options.expose ? { expose: options.expose } : {}),
185
+ ...(options.metrics !== undefined ? { metrics: options.metrics } : {}),
186
+ ...(local ? { endpoints: [{ type: "local", address: "local://in-process" }] } : {}),
187
+ };
188
+ }
250
189
 
251
- // Select URL strategy based on config
252
- // Default to patternUrlStrategy which matches the client's defaultUrlPattern
253
- const urlStrategy = config.urlStrategy === "rpc"
254
- ? rpcServerUrlStrategy
255
- : config.urlStrategy === "rest"
256
- ? defaultServerUrlStrategy
257
- : patternUrlStrategy;
190
+ /** The cap of the stage `drain` of the profile `daemon`. */
191
+ const DAEMON_DRAIN = daemon.phases?.find((phase) => phase.name === "drain")?.cap ?? seconds(4);
258
192
 
259
- const httpTransport = new HttpServerTransport(this.server, {
260
- app,
261
- port,
262
- host,
263
- basePath,
264
- // Off by default: before, every peer sent Access-Control-Allow-Origin: * (deep dive CLI-2)
265
- cors: config.cors ?? false,
266
- urlStrategy,
267
- // The transport's browser checks follow the peer's rule: loopback origins and the listed
268
- // ones (the token is required in any case). The RESTful strategy calls by GET.
269
- allowedOrigins: this.browser.allowedOrigins ?? ((origin) => originAllowed(origin, config.corsOrigins)),
270
- ...(this.browser.allowedHosts ? { allowedHosts: this.browser.allowedHosts } : {}),
271
- allowGet: this.browser.allowGet ?? config.urlStrategy === "rest",
272
- ...(config.bodyLimit !== undefined ? { bodyLimit: config.bodyLimit } : {}),
273
- });
193
+ /**
194
+ * The profile of the service of a peer, as data: the caps of `daemon`, and the cooperative abort at
195
+ * the start of the drain. Thus `stop()` aborts the calls in flight at once, as `stop()` without a
196
+ * grace did before (CLI-7). A repeat signal has no effect, and the port of a peer has no signal.
197
+ */
198
+ export const PEER_PROFILE: Profile = Object.freeze({ ...daemon, cooperativeWindow: DAEMON_DRAIN, cooperativeFloor: "none" as const });
274
199
 
275
- this.server.addTransport(httpTransport);
276
- await httpTransport.start();
200
+ class PeerImpl implements Peer {
201
+ readonly id: string;
202
+ private readonly host: ProcedureHost;
203
+ private readonly profile: Profile;
204
+ private life: Lifecycle | undefined;
205
+ private state: "new" | "started" | "stopped" = "new";
277
206
 
278
- this.endpoints.push({
279
- type: "http",
280
- // The real port: with port 0 the system gives a free port
281
- address: `http://${host === "0.0.0.0" ? "localhost" : host}:${httpTransport.port() ?? port}${basePath}`,
282
- });
207
+ constructor(options: PeerOptions) {
208
+ this.id = options.id;
209
+ this.host = createProcedureHost(peerHostOptions(options));
210
+ this.profile = options.profile ?? PEER_PROFILE;
283
211
  }
284
212
 
285
- private async startWebSocketTransport(config: WebSocketTransportConfig): Promise<void> {
286
- // WebSocket transport requires an HTTP server to attach to
287
- // For standalone WebSocket, we create an HTTP server first
288
- const { createServer } = await import("http");
289
-
290
- const port = config.port ?? 3001;
291
- // Loopback by default so the full procedure registry (incl. shell.run / fs.write) is not
292
- // served unauthenticated on all interfaces. Pass host: "0.0.0.0" to expose on the LAN
293
- // deliberately. See documentation/BUGS-2026-07.md (H19).
294
- const host = config.host ?? "127.0.0.1";
295
- const path = config.path ?? "/ws";
296
-
297
- const httpServer = createServer();
298
-
299
- // Listen first, then attach the WebSocket server. A listen error (EADDRINUSE, EACCES) rejects:
300
- // before, the error had no listener, so the process ended with an unhandled 'error' event
301
- // (deep dive CLI-16).
302
- await new Promise<void>((resolve, reject) => {
303
- const onError = (error: Error): void => {
304
- reject(new Error(`The WebSocket server cannot listen on ${host}:${port}: ${error.message}`));
305
- };
306
- httpServer.once("error", onError);
307
- httpServer.listen(port, host, () => {
308
- httpServer.off("error", onError);
309
- resolve();
310
- });
311
- });
312
-
313
- const wsTransport = new WebSocketServerTransport(this.server, {
314
- server: httpServer,
315
- path,
316
- allowedOrigins: this.browser.allowedOrigins ?? ((origin) => originAllowed(origin, config.origins)),
317
- ...(this.browser.allowedHosts ? { allowedHosts: this.browser.allowedHosts } : {}),
318
- // Host, origin and token checks: before, any web page could open a connection and call
319
- // procedures (cross-site WebSocket hijacking, deep dive TRN-1)
320
- authenticate: (req) => {
321
- const token = new URL(req.url ?? "/", "http://localhost").searchParams.get("token") ?? bearerToken(req.headers.authorization);
322
- return (
323
- hostAllowed(host, req.headers.host) &&
324
- originAllowed(req.headers.origin, config.origins) &&
325
- tokenMatches(config.token, token)
326
- );
327
- },
328
- });
329
-
330
- this.server.addTransport(wsTransport);
331
- try {
332
- await wsTransport.start();
333
- } catch (error) {
334
- httpServer.close();
335
- throw error;
213
+ async start(): Promise<void> {
214
+ if (this.state === "started") return;
215
+ if (this.state === "stopped") throw new Error("This peer stopped. A peer starts one time: create a new peer.");
216
+ this.state = "started";
217
+ const svc = this.host.declare(service(this.id).profile(this.profile));
218
+ // No check gates the policies of a peer: it is ready from the end of its startup
219
+ if (!this.profile.readiness) svc.readiness({ type: "debounce", interval: seconds(5), failAfter: 3, recoverAfter: 1 });
220
+ if (!this.profile.liveness) svc.liveness({ type: "window", failAfter: seconds(45) });
221
+ if (!this.profile.startup) svc.startup(seconds(30));
222
+ if (!this.profile.onRepeatSignal) svc.onRepeatSignal("ignore");
223
+ // The port of a lifecycle in a process that another part owns (SYS-27): no signal handler, no
224
+ // hold of the process, and an exit that only records the code
225
+ const life = await svc.run({ process: embeddedProcess() });
226
+ if (life.state !== "running") {
227
+ // The startup failed: the lifecycle stops the parts that started, so no port stays open
228
+ this.state = "stopped";
229
+ await life.done;
230
+ throw new Error(life.errors.join("; ") || "The peer did not start");
336
231
  }
337
- this.ownedServers.push(httpServer);
338
- this.wsTransports.push(wsTransport);
339
-
340
- this.endpoints.push({
341
- type: "websocket",
342
- address: `ws://${host === "0.0.0.0" ? "localhost" : host}:${listeningPort(httpServer) ?? port}${path}`,
343
- });
232
+ this.life = life;
344
233
  }
345
234
 
346
- async stop(options: StopOptions = {}): Promise<void> {
347
- if (!this.started) return;
348
- await this.server.stop(options);
349
- // The HTTP servers of the WebSocket transports: before, they stayed open, so the port stayed
350
- // bound after stop() (roadmap R5)
351
- const owned = this.ownedServers;
352
- this.ownedServers = [];
353
- await Promise.all(
354
- owned.map(
355
- (httpServer) =>
356
- new Promise<void>((resolve) => {
357
- httpServer.close(() => resolve());
358
- httpServer.closeAllConnections();
359
- }),
360
- ),
361
- );
362
- this.started = false;
363
- this.endpoints = [];
364
- this.wsTransports = [];
235
+ async stop(_options: StopOptions = {}): Promise<void> {
236
+ const life = this.life;
237
+ if (this.state !== "started" || life === undefined) return;
238
+ this.state = "stopped";
239
+ await life.shutdown("the peer stops");
240
+ await life.done;
365
241
  }
366
242
 
367
243
  getEndpoints(): PeerEndpoint[] {
368
- return [...this.endpoints];
244
+ if (this.state !== "started" || this.life === undefined) return [];
245
+ return this.host.info.endpoints().map((endpoint) => ({ type: endpoint.type as TransportType, address: endpoint.address }));
369
246
  }
370
247
 
371
248
  getServer(): ProcedureServer {
372
- return this.server;
249
+ return this.host.server;
250
+ }
251
+
252
+ getHost(): ProcedureHost {
253
+ return this.host;
373
254
  }
374
255
 
375
256
  connections(): string[] {
376
- return this.wsTransports.flatMap((transport) => transport.getTrackedConnections().map((connection) => connection.id));
257
+ return this.host.connections();
377
258
  }
378
259
 
379
260
  clientFor(connectionId: string): Client {
380
- const transport = this.wsTransports.find((ws) => ws.getTrackedConnection(connectionId) !== undefined);
381
- if (!transport) throw new Error(`Connection not found: ${connectionId}`);
382
- return new Client({ transport: transport.transportFor(connectionId), throwOnError: true });
261
+ return this.host.clientFor(connectionId);
383
262
  }
384
263
  }
385
264
 
@@ -388,7 +267,8 @@ class PeerImpl implements Peer {
388
267
  // =============================================================================
389
268
 
390
269
  /**
391
- * Create a new peer instance
270
+ * Create a new peer instance: a procedures host with a service of its own, for a process that has
271
+ * no service of its own. A process with a service uses `createProcedureHost()` and `host.declare(svc)`.
392
272
  */
393
273
  export async function createPeer(options: PeerOptions): Promise<Peer> {
394
274
  return new PeerImpl(options);