@procedures/peer 0.0.0-stage → 0.1.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.
Files changed (135) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +725 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/connection/index.d.ts +45 -0
  6. package/dist/connection/index.d.ts.map +1 -0
  7. package/dist/connection/index.js +148 -0
  8. package/dist/connection/index.js.map +1 -0
  9. package/dist/discovery/index.d.ts +30 -0
  10. package/dist/discovery/index.d.ts.map +1 -0
  11. package/dist/discovery/index.js +101 -0
  12. package/dist/discovery/index.js.map +1 -0
  13. package/dist/expose.d.ts +14 -0
  14. package/dist/expose.d.ts.map +1 -0
  15. package/dist/expose.js +16 -0
  16. package/dist/expose.js.map +1 -0
  17. package/dist/index.d.ts +15 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +20 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/input-schemas.d.ts +21 -0
  22. package/dist/input-schemas.d.ts.map +1 -0
  23. package/dist/input-schemas.js +80 -0
  24. package/dist/input-schemas.js.map +1 -0
  25. package/dist/lockfile.d.ts +48 -0
  26. package/dist/lockfile.d.ts.map +1 -0
  27. package/dist/lockfile.js +115 -0
  28. package/dist/lockfile.js.map +1 -0
  29. package/dist/manifest/index.d.ts +8 -0
  30. package/dist/manifest/index.d.ts.map +1 -0
  31. package/dist/manifest/index.js +8 -0
  32. package/dist/manifest/index.js.map +1 -0
  33. package/dist/manifest/json.d.ts +27 -0
  34. package/dist/manifest/json.d.ts.map +1 -0
  35. package/dist/manifest/json.js +24 -0
  36. package/dist/manifest/json.js.map +1 -0
  37. package/dist/manifest/typescript.d.ts +17 -0
  38. package/dist/manifest/typescript.d.ts.map +1 -0
  39. package/dist/manifest/typescript.js +123 -0
  40. package/dist/manifest/typescript.js.map +1 -0
  41. package/dist/module.d.ts +20 -0
  42. package/dist/module.d.ts.map +1 -0
  43. package/dist/module.js +42 -0
  44. package/dist/module.js.map +1 -0
  45. package/dist/output-schemas.d.ts +21 -0
  46. package/dist/output-schemas.d.ts.map +1 -0
  47. package/dist/output-schemas.js +85 -0
  48. package/dist/output-schemas.js.map +1 -0
  49. package/dist/peer/index.d.ts +47 -0
  50. package/dist/peer/index.d.ts.map +1 -0
  51. package/dist/peer/index.js +234 -0
  52. package/dist/peer/index.js.map +1 -0
  53. package/dist/peer/security.d.ts +25 -0
  54. package/dist/peer/security.d.ts.map +1 -0
  55. package/dist/peer/security.js +58 -0
  56. package/dist/peer/security.js.map +1 -0
  57. package/dist/procedures/index.d.ts +24 -0
  58. package/dist/procedures/index.d.ts.map +1 -0
  59. package/dist/procedures/index.js +15 -0
  60. package/dist/procedures/index.js.map +1 -0
  61. package/dist/procedures/manifest.generate.d.ts +11 -0
  62. package/dist/procedures/manifest.generate.d.ts.map +1 -0
  63. package/dist/procedures/manifest.generate.js +97 -0
  64. package/dist/procedures/manifest.generate.js.map +1 -0
  65. package/dist/procedures/server.call.d.ts +30 -0
  66. package/dist/procedures/server.call.d.ts.map +1 -0
  67. package/dist/procedures/server.call.js +60 -0
  68. package/dist/procedures/server.call.js.map +1 -0
  69. package/dist/procedures/server.connect.d.ts +11 -0
  70. package/dist/procedures/server.connect.d.ts.map +1 -0
  71. package/dist/procedures/server.connect.js +35 -0
  72. package/dist/procedures/server.connect.js.map +1 -0
  73. package/dist/procedures/server.connections.d.ts +22 -0
  74. package/dist/procedures/server.connections.d.ts.map +1 -0
  75. package/dist/procedures/server.connections.js +38 -0
  76. package/dist/procedures/server.connections.js.map +1 -0
  77. package/dist/procedures/server.create.d.ts +20 -0
  78. package/dist/procedures/server.create.d.ts.map +1 -0
  79. package/dist/procedures/server.create.js +64 -0
  80. package/dist/procedures/server.create.js.map +1 -0
  81. package/dist/procedures/server.disconnect.d.ts +11 -0
  82. package/dist/procedures/server.disconnect.d.ts.map +1 -0
  83. package/dist/procedures/server.disconnect.js +22 -0
  84. package/dist/procedures/server.disconnect.js.map +1 -0
  85. package/dist/procedures/server.start.d.ts +13 -0
  86. package/dist/procedures/server.start.d.ts.map +1 -0
  87. package/dist/procedures/server.start.js +179 -0
  88. package/dist/procedures/server.start.js.map +1 -0
  89. package/dist/procedures/server.status.d.ts +12 -0
  90. package/dist/procedures/server.status.d.ts.map +1 -0
  91. package/dist/procedures/server.status.js +116 -0
  92. package/dist/procedures/server.status.js.map +1 -0
  93. package/dist/procedures/server.stop.d.ts +11 -0
  94. package/dist/procedures/server.stop.d.ts.map +1 -0
  95. package/dist/procedures/server.stop.js +96 -0
  96. package/dist/procedures/server.stop.js.map +1 -0
  97. package/dist/register.d.ts +8 -0
  98. package/dist/register.d.ts.map +1 -0
  99. package/dist/register.js +14 -0
  100. package/dist/register.js.map +1 -0
  101. package/dist/schema.d.ts +14 -0
  102. package/dist/schema.d.ts.map +1 -0
  103. package/dist/schema.js +14 -0
  104. package/dist/schema.js.map +1 -0
  105. package/dist/types.d.ts +192 -0
  106. package/dist/types.d.ts.map +1 -0
  107. package/dist/types.js +5 -0
  108. package/dist/types.js.map +1 -0
  109. package/package.json +87 -3
  110. package/src/connection/index.ts +219 -0
  111. package/src/discovery/index.ts +137 -0
  112. package/src/expose.ts +17 -0
  113. package/src/index.ts +69 -0
  114. package/src/input-schemas.ts +121 -0
  115. package/src/lockfile.ts +129 -0
  116. package/src/manifest/index.ts +8 -0
  117. package/src/manifest/json.ts +39 -0
  118. package/src/manifest/typescript.ts +148 -0
  119. package/src/module.ts +46 -0
  120. package/src/output-schemas.ts +110 -0
  121. package/src/peer/index.ts +325 -0
  122. package/src/peer/security.ts +58 -0
  123. package/src/procedures/index.ts +32 -0
  124. package/src/procedures/manifest.generate.ts +113 -0
  125. package/src/procedures/server.call.ts +96 -0
  126. package/src/procedures/server.connect.ts +45 -0
  127. package/src/procedures/server.connections.ts +64 -0
  128. package/src/procedures/server.create.ts +84 -0
  129. package/src/procedures/server.disconnect.ts +31 -0
  130. package/src/procedures/server.start.ts +209 -0
  131. package/src/procedures/server.status.ts +139 -0
  132. package/src/procedures/server.stop.ts +121 -0
  133. package/src/register.ts +17 -0
  134. package/src/schema.ts +15 -0
  135. package/src/types.ts +257 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Peer Module
3
+ *
4
+ * Transport-agnostic peer implementation for bidirectional RPC.
5
+ */
6
+
7
+ import {
8
+ ProcedureServer,
9
+ HttpServerTransport,
10
+ WebSocketServerTransport,
11
+ type ProcedureRegistry,
12
+ PROCEDURE_REGISTRY,
13
+ rpcServerUrlStrategy,
14
+ defaultServerUrlStrategy,
15
+ defaultUrlPattern,
16
+ procedureServerOptions,
17
+ type BrowserGuardOptions,
18
+ type HostPolicy,
19
+ type Method,
20
+ } from "@procedures/client";
21
+ import type { Request } from "express";
22
+ import type { ProcedurePath } from "@procedures/client";
23
+ import type { HttpTransportConfig, TransportConfig, TransportType, WebSocketTransportConfig } from "../types.js";
24
+ import { bearerToken, hostAllowed, originAllowed, tokenMatches } from "./security.js";
25
+
26
+ /**
27
+ * URL strategy that matches the client's defaultUrlPattern
28
+ * Maps: POST /api/server/status → { service: "server", operation: "status" }
29
+ */
30
+ function patternUrlStrategy(req: Request): Method | null {
31
+ const basePath = "/api";
32
+ let path = req.path;
33
+
34
+ // Remove base path prefix if present
35
+ if (path.startsWith(basePath)) {
36
+ path = path.slice(basePath.length);
37
+ }
38
+
39
+ // Use the client's defaultUrlPattern parser
40
+ // Cast method to expected type (parser doesn't actually use it for this pattern)
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ return defaultUrlPattern.parse(path, req.method.toUpperCase() as any);
43
+ }
44
+
45
+ // =============================================================================
46
+ // Types
47
+ // =============================================================================
48
+
49
+ export interface PeerOptions {
50
+ /** Unique peer ID */
51
+ id: string;
52
+ /** Transports to enable */
53
+ transports: TransportConfig[];
54
+ /** Auto-register procedures from registry */
55
+ autoRegister?: boolean;
56
+ /** Procedure registry to use */
57
+ registry?: ProcedureRegistry;
58
+ /** The procedures that the peer serves (see `exposeRule`). Without it, the peer serves every procedure. */
59
+ expose?: ((path: ProcedurePath) => boolean) | undefined;
60
+ /**
61
+ * The policy of the host (`hostPolicy()` of the core, roadmap 2.3): the expose rule, the call
62
+ * rule, the interceptors (a time limit), the token of each network transport that has none of
63
+ * its own, and the browser checks. `expose` above, when given, wins over the policy's.
64
+ */
65
+ policy?: HostPolicy | undefined;
66
+ }
67
+
68
+ export interface PeerEndpoint {
69
+ type: TransportType;
70
+ address: string;
71
+ }
72
+
73
+ export interface Peer {
74
+ /** Peer ID */
75
+ id: string;
76
+ /** Start all transports */
77
+ start(): Promise<void>;
78
+ /** Stop all transports */
79
+ stop(): Promise<void>;
80
+ /** Get active endpoints */
81
+ getEndpoints(): PeerEndpoint[];
82
+ /** Get underlying server */
83
+ getServer(): ProcedureServer;
84
+ }
85
+
86
+ // =============================================================================
87
+ // Implementation
88
+ // =============================================================================
89
+
90
+ class PeerImpl implements Peer {
91
+ readonly id: string;
92
+ private server: ProcedureServer;
93
+ private transports: TransportConfig[];
94
+ private endpoints: PeerEndpoint[] = [];
95
+ private started = false;
96
+ private registry: ProcedureRegistry;
97
+ private startTime: number = Date.now();
98
+
99
+ /** The browser checks of the policy (an origin rule here replaces the peer's own rule). */
100
+ private browser: BrowserGuardOptions;
101
+
102
+ constructor(options: PeerOptions) {
103
+ this.id = options.id;
104
+ const policy = options.policy;
105
+ // The policy's token goes to each network transport that has none of its own
106
+ this.transports = options.transports.map((transport) =>
107
+ transport.type === "local" || policy?.token === undefined || transport.token !== undefined
108
+ ? transport
109
+ : { ...transport, token: policy.token }
110
+ );
111
+ this.registry = options.registry ?? PROCEDURE_REGISTRY;
112
+ this.browser = policy?.browser ?? {};
113
+
114
+ // Create procedure server
115
+ this.server = new ProcedureServer({
116
+ ...(policy ? procedureServerOptions(policy, this.registry) : {}),
117
+ autoRegister: options.autoRegister ?? true,
118
+ registry: this.registry,
119
+ ...(options.expose ? { expose: options.expose } : {}),
120
+ });
121
+ }
122
+
123
+ async start(): Promise<void> {
124
+ if (this.started) return;
125
+
126
+ try {
127
+ for (const transport of this.transports) {
128
+ await this.startTransport(transport);
129
+ }
130
+ } catch (error) {
131
+ // Stop the transports that started, so a failed start leaves no port open
132
+ await this.server.stop().catch(() => {});
133
+ this.endpoints = [];
134
+ throw error;
135
+ }
136
+
137
+ this.started = true;
138
+ }
139
+
140
+ private async startTransport(config: TransportConfig): Promise<void> {
141
+ switch (config.type) {
142
+ case "http":
143
+ await this.startHttpTransport(config);
144
+ break;
145
+ case "websocket":
146
+ await this.startWebSocketTransport(config);
147
+ break;
148
+ case "local":
149
+ // Local transport doesn't need network setup
150
+ this.endpoints.push({
151
+ type: "local",
152
+ address: "local://in-process",
153
+ });
154
+ break;
155
+ }
156
+ }
157
+
158
+ private async startHttpTransport(config: HttpTransportConfig): Promise<void> {
159
+ // Dynamic import to avoid bundling express if not used
160
+ const express = (await import("express")).default;
161
+
162
+ const app = express();
163
+ app.use(express.json());
164
+
165
+ const port = config.port ?? 3000;
166
+ // Loopback by default so the full procedure registry (incl. shell.run / fs.write) is not
167
+ // served unauthenticated on all interfaces. Pass host: "0.0.0.0" to expose on the LAN
168
+ // deliberately. See documentation/BUGS-2026-07.md (H19).
169
+ const host = config.host ?? "127.0.0.1";
170
+ const basePath = config.basePath ?? "/api";
171
+
172
+ // Host, origin and token checks (deep dive TRN-1, CLI-1, CLI-2). The health endpoints need no
173
+ // token: a client uses them to check that a lockfile's server is alive.
174
+ app.use((req, res, next) => {
175
+ if (!hostAllowed(host, req.headers.host) || !originAllowed(req.headers.origin, config.corsOrigins)) {
176
+ res.status(403).json({ error: "Forbidden: the host or the origin is not allowed", code: "FORBIDDEN" });
177
+ return;
178
+ }
179
+ const isHealth = req.path === "/health" || req.path === `${basePath}/health`;
180
+ if (!isHealth && req.method !== "OPTIONS" && !tokenMatches(config.token, bearerToken(req.headers.authorization))) {
181
+ res.status(401).json({ error: "Unauthorized: this server requires its token", code: "UNAUTHORIZED" });
182
+ return;
183
+ }
184
+ next();
185
+ });
186
+
187
+ // Health endpoint for container orchestration
188
+ app.get(`${basePath}/health`, (_req, res) => {
189
+ const procedureCount = this.registry.getAll().filter((p) => p.handler).length;
190
+ res.json({
191
+ status: "ok",
192
+ uptime: Date.now() - this.startTime,
193
+ procedures: procedureCount,
194
+ peerId: this.id,
195
+ });
196
+ });
197
+
198
+ // Also add at root /health for convenience
199
+ app.get("/health", (_req, res) => {
200
+ const procedureCount = this.registry.getAll().filter((p) => p.handler).length;
201
+ res.json({
202
+ status: "ok",
203
+ uptime: Date.now() - this.startTime,
204
+ procedures: procedureCount,
205
+ peerId: this.id,
206
+ });
207
+ });
208
+
209
+ // Select URL strategy based on config
210
+ // Default to patternUrlStrategy which matches the client's defaultUrlPattern
211
+ const urlStrategy = config.urlStrategy === "rpc"
212
+ ? rpcServerUrlStrategy
213
+ : config.urlStrategy === "rest"
214
+ ? defaultServerUrlStrategy
215
+ : patternUrlStrategy;
216
+
217
+ const httpTransport = new HttpServerTransport(this.server, {
218
+ app,
219
+ port,
220
+ host,
221
+ basePath,
222
+ // Off by default: before, every peer sent Access-Control-Allow-Origin: * (deep dive CLI-2)
223
+ cors: config.cors ?? false,
224
+ urlStrategy,
225
+ // The transport's browser checks follow the peer's rule: loopback origins and the listed
226
+ // ones (the token is required in any case). The RESTful strategy calls by GET.
227
+ allowedOrigins: this.browser.allowedOrigins ?? ((origin) => originAllowed(origin, config.corsOrigins)),
228
+ ...(this.browser.allowedHosts ? { allowedHosts: this.browser.allowedHosts } : {}),
229
+ allowGet: this.browser.allowGet ?? config.urlStrategy === "rest",
230
+ });
231
+
232
+ this.server.addTransport(httpTransport);
233
+ await httpTransport.start();
234
+
235
+ this.endpoints.push({
236
+ type: "http",
237
+ address: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${basePath}`,
238
+ });
239
+ }
240
+
241
+ private async startWebSocketTransport(config: WebSocketTransportConfig): Promise<void> {
242
+ // WebSocket transport requires an HTTP server to attach to
243
+ // For standalone WebSocket, we create an HTTP server first
244
+ const { createServer } = await import("http");
245
+
246
+ const port = config.port ?? 3001;
247
+ // Loopback by default so the full procedure registry (incl. shell.run / fs.write) is not
248
+ // served unauthenticated on all interfaces. Pass host: "0.0.0.0" to expose on the LAN
249
+ // deliberately. See documentation/BUGS-2026-07.md (H19).
250
+ const host = config.host ?? "127.0.0.1";
251
+ const path = config.path ?? "/ws";
252
+
253
+ const httpServer = createServer();
254
+
255
+ // Listen first, then attach the WebSocket server. A listen error (EADDRINUSE, EACCES) rejects:
256
+ // before, the error had no listener, so the process ended with an unhandled 'error' event
257
+ // (deep dive CLI-16).
258
+ await new Promise<void>((resolve, reject) => {
259
+ const onError = (error: Error): void => {
260
+ reject(new Error(`The WebSocket server cannot listen on ${host}:${port}: ${error.message}`));
261
+ };
262
+ httpServer.once("error", onError);
263
+ httpServer.listen(port, host, () => {
264
+ httpServer.off("error", onError);
265
+ resolve();
266
+ });
267
+ });
268
+
269
+ const wsTransport = new WebSocketServerTransport(this.server, {
270
+ server: httpServer,
271
+ path,
272
+ allowedOrigins: this.browser.allowedOrigins ?? ((origin) => originAllowed(origin, config.origins)),
273
+ ...(this.browser.allowedHosts ? { allowedHosts: this.browser.allowedHosts } : {}),
274
+ // Host, origin and token checks: before, any web page could open a connection and call
275
+ // procedures (cross-site WebSocket hijacking, deep dive TRN-1)
276
+ authenticate: (req) => {
277
+ const token = new URL(req.url ?? "/", "http://localhost").searchParams.get("token") ?? bearerToken(req.headers.authorization);
278
+ return (
279
+ hostAllowed(host, req.headers.host) &&
280
+ originAllowed(req.headers.origin, config.origins) &&
281
+ tokenMatches(config.token, token)
282
+ );
283
+ },
284
+ });
285
+
286
+ this.server.addTransport(wsTransport);
287
+ try {
288
+ await wsTransport.start();
289
+ } catch (error) {
290
+ httpServer.close();
291
+ throw error;
292
+ }
293
+
294
+ this.endpoints.push({
295
+ type: "websocket",
296
+ address: `ws://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`,
297
+ });
298
+ }
299
+
300
+ async stop(): Promise<void> {
301
+ if (!this.started) return;
302
+ await this.server.stop();
303
+ this.started = false;
304
+ this.endpoints = [];
305
+ }
306
+
307
+ getEndpoints(): PeerEndpoint[] {
308
+ return [...this.endpoints];
309
+ }
310
+
311
+ getServer(): ProcedureServer {
312
+ return this.server;
313
+ }
314
+ }
315
+
316
+ // =============================================================================
317
+ // Factory
318
+ // =============================================================================
319
+
320
+ /**
321
+ * Create a new peer instance
322
+ */
323
+ export async function createPeer(options: PeerOptions): Promise<Peer> {
324
+ return new PeerImpl(options);
325
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Request checks of a peer (deep dive TRN-1, CLI-1, CLI-2).
3
+ *
4
+ * A peer serves the whole registry, `shell.*` included. So:
5
+ * - a peer bound to loopback answers only requests addressed to a loopback name (a DNS
6
+ * rebinding attack sends another name);
7
+ * - a browser request is accepted only from a loopback origin or an origin in the allowlist
8
+ * (before, CORS was on by default with `Access-Control-Allow-Origin: *`, so any web page could
9
+ * call the server);
10
+ * - when the peer has a token, every request must carry it: `Authorization: Bearer <token>` over
11
+ * HTTP, `?token=<token>` for a WebSocket connection (a browser WebSocket cannot set headers).
12
+ */
13
+
14
+ import { timingSafeEqual } from "node:crypto";
15
+
16
+ const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
17
+
18
+ /** True for a bind address that only this computer can reach. */
19
+ export function isLoopback(host: string): boolean {
20
+ return LOOPBACK_NAMES.has(host);
21
+ }
22
+
23
+ /** The name of a Host header without its port: "localhost:3000" gives "localhost", "[::1]:80" gives "[::1]". */
24
+ export function hostName(header: string | undefined): string {
25
+ if (!header) return "";
26
+ if (header.startsWith("[")) return header.slice(0, header.indexOf("]") + 1).toLowerCase();
27
+ return (header.split(":")[0] ?? "").toLowerCase();
28
+ }
29
+
30
+ /** A peer bound to loopback accepts only a loopback Host header. */
31
+ export function hostAllowed(bindHost: string, header: string | undefined): boolean {
32
+ return !isLoopback(bindHost) || LOOPBACK_NAMES.has(hostName(header));
33
+ }
34
+
35
+ /** A request without Origin (not a browser) passes; a browser origin must be loopback or in the allowlist. */
36
+ export function originAllowed(origin: string | undefined, allowlist: readonly string[] = []): boolean {
37
+ if (origin === undefined) return true;
38
+ if (allowlist.includes(origin)) return true;
39
+ try {
40
+ return LOOPBACK_NAMES.has(new URL(origin).hostname);
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** True when no token is required, or when the presented token is the expected one (a constant-time comparison). */
47
+ export function tokenMatches(expected: string | undefined, presented: string | undefined | null): boolean {
48
+ if (expected === undefined) return true;
49
+ if (!presented) return false;
50
+ const a = Buffer.from(expected);
51
+ const b = Buffer.from(presented);
52
+ return a.length === b.length && timingSafeEqual(a, b);
53
+ }
54
+
55
+ /** The token of an `Authorization: Bearer <token>` header. */
56
+ export function bearerToken(header: string | undefined): string | undefined {
57
+ return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
58
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Server Procedures
3
+ *
4
+ * All procedures exported from this module.
5
+ */
6
+
7
+ export { serverCreateProcedure, getPeer, getActivePeerIds } from "./server.create.js";
8
+ export type { ServerCreateInput, ServerCreateOutput } from "./server.create.js";
9
+
10
+ export { serverConnectProcedure } from "./server.connect.js";
11
+ export type { ServerConnectInput, ServerConnectOutput } from "./server.connect.js";
12
+
13
+ export { serverDisconnectProcedure } from "./server.disconnect.js";
14
+ export type { ServerDisconnectInput, ServerDisconnectOutput } from "./server.disconnect.js";
15
+
16
+ export { manifestGenerateProcedure } from "./manifest.generate.js";
17
+ export type { ManifestGenerateInput, ManifestGenerateOutput } from "./manifest.generate.js";
18
+
19
+ export { serverStartProcedure } from "./server.start.js";
20
+ export type { ServerStartInput, ServerStartOutput } from "./server.start.js";
21
+
22
+ export { serverStopProcedure } from "./server.stop.js";
23
+ export type { ServerStopInput, ServerStopOutput } from "./server.stop.js";
24
+
25
+ export { serverStatusProcedure } from "./server.status.js";
26
+ export type { ServerStatusInput, ServerStatusOutput } from "./server.status.js";
27
+
28
+ export { serverCallProcedure } from "./server.call.js";
29
+ export type { ServerCallInput, ServerCallOutput } from "./server.call.js";
30
+
31
+ export { serverConnectionsProcedure } from "./server.connections.js";
32
+ export type { ServerConnectionsInput, ServerConnectionsOutput } from "./server.connections.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Procedure: manifest.generate
3
+ * Generates procedure manifests in various formats
4
+ */
5
+
6
+ import { createProcedure, jsonSchemaOf, type Procedure, PROCEDURE_REGISTRY } from "@procedures/client";
7
+ import { wrapSchema } from "../schema.js";
8
+ import { ManifestGenerateOutputSchema } from "../output-schemas.js";
9
+ import { manifestGenerateInputSchema } from "../input-schemas.js";
10
+ import type { ManifestGenerateInput, ManifestGenerateOutput, ProcedureInfo } from "../types.js";
11
+ import { generateTypeScript } from "../manifest/typescript.js";
12
+ import { generateJson } from "../manifest/json.js";
13
+ import { writeFile, mkdir } from "fs/promises";
14
+ import { join, dirname } from "path";
15
+
16
+ // Use pass-through schemas
17
+ const manifestGenerateOutputSchema = wrapSchema<ManifestGenerateOutput>(ManifestGenerateOutputSchema);
18
+
19
+ export const manifestGenerateProcedure: Procedure<
20
+ ManifestGenerateInput,
21
+ ManifestGenerateOutput,
22
+ { description: string }
23
+ > = createProcedure()
24
+ .path(["manifest", "generate"])
25
+ .input(manifestGenerateInputSchema)
26
+ .output(manifestGenerateOutputSchema)
27
+ .meta({ description: "Generate procedure manifests in various formats" })
28
+ .handler(async (input: ManifestGenerateInput) => {
29
+ // Get all registered procedures
30
+ const allProcedures = PROCEDURE_REGISTRY.getAll();
31
+
32
+ // Filter by namespace if specified
33
+ let procedures = allProcedures;
34
+ if (input.namespace && input.namespace.length > 0) {
35
+ procedures = allProcedures.filter((proc) => {
36
+ // Check if procedure path starts with namespace
37
+ for (let i = 0; i < input.namespace!.length; i++) {
38
+ if (proc.path[i] !== input.namespace![i]) {
39
+ return false;
40
+ }
41
+ }
42
+ return true;
43
+ });
44
+ }
45
+
46
+ // Convert to ProcedureInfo format
47
+ const procedureInfos: ProcedureInfo[] = procedures.map((proc) => {
48
+ const meta = proc.metadata as { description?: string; internal?: boolean } | undefined;
49
+ const info: ProcedureInfo = {
50
+ path: proc.path,
51
+ };
52
+ if (meta?.description !== undefined) {
53
+ info.description = meta.description;
54
+ }
55
+ // The JSON Schemas of the input and the output, when the schemas can describe themselves
56
+ const inputSchema = jsonSchemaOf(proc.input, { io: "input" });
57
+ const outputSchema = jsonSchemaOf(proc.output, { io: "output" });
58
+ if (inputSchema) info.inputSchema = inputSchema;
59
+ if (outputSchema) info.outputSchema = outputSchema;
60
+ return info;
61
+ });
62
+
63
+ // Determine formats to generate
64
+ const formats = input.formats ?? ["json"];
65
+ const result: ManifestGenerateOutput = {};
66
+ const filesWritten: string[] = [];
67
+
68
+ // Generate JSON manifest
69
+ if (formats.includes("json")) {
70
+ const jsonManifest = generateJson(procedureInfos);
71
+ result.json = jsonManifest;
72
+
73
+ if (input.outputDir) {
74
+ const filePath = join(input.outputDir, "procedures.json");
75
+ await ensureDir(dirname(filePath));
76
+ await writeFile(filePath, JSON.stringify(jsonManifest, null, 2));
77
+ filesWritten.push(filePath);
78
+ }
79
+ }
80
+
81
+ // Generate TypeScript declarations
82
+ if (formats.includes("typescript")) {
83
+ const typescript = generateTypeScript(procedureInfos);
84
+ result.typescript = typescript;
85
+
86
+ if (input.outputDir) {
87
+ const filePath = join(input.outputDir, "procedures.d.ts");
88
+ await ensureDir(dirname(filePath));
89
+ await writeFile(filePath, typescript);
90
+ filesWritten.push(filePath);
91
+ }
92
+ }
93
+
94
+ if (filesWritten.length > 0) {
95
+ result.filesWritten = filesWritten;
96
+ }
97
+
98
+ return result;
99
+ })
100
+ .build();
101
+
102
+ /**
103
+ * Ensure directory exists
104
+ */
105
+ async function ensureDir(dir: string): Promise<void> {
106
+ try {
107
+ await mkdir(dir, { recursive: true });
108
+ } catch {
109
+ // Directory may already exist
110
+ }
111
+ }
112
+
113
+ export type { ManifestGenerateInput, ManifestGenerateOutput };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Procedure: server.call
3
+ * Call a procedure on a remote peer through an outbound connection
4
+ */
5
+
6
+ import { createProcedure, pathToMethod, type Procedure } from "@procedures/client";
7
+ import { wrapSchema } from "../schema.js";
8
+ import { ServerCallOutputSchema } from "../output-schemas.js";
9
+ import { serverCallInputSchema } from "../input-schemas.js";
10
+ import { getConnection, connect } from "../connection/index.js";
11
+
12
+ // =============================================================================
13
+ // Types
14
+ // =============================================================================
15
+
16
+ export interface ServerCallInput {
17
+ /** Connection ID (from server.connect) */
18
+ connectionId?: string | undefined;
19
+ /** Remote address (alternative to connectionId - creates one-shot connection) */
20
+ address?: string | undefined;
21
+ /** Procedure path on the remote peer - dot-separated string or array */
22
+ path: string | string[];
23
+ /** Input payload for the remote procedure */
24
+ input?: unknown;
25
+ }
26
+
27
+ export interface ServerCallOutput {
28
+ /** Result from the remote procedure */
29
+ result: unknown;
30
+ /** Connection ID used */
31
+ connectionId: string;
32
+ /** Procedure path called */
33
+ path: string[];
34
+ }
35
+
36
+ // =============================================================================
37
+ // Procedure
38
+ // =============================================================================
39
+
40
+ const serverCallOutputSchema = wrapSchema<ServerCallOutput>(ServerCallOutputSchema);
41
+
42
+ export const serverCallProcedure: Procedure<
43
+ ServerCallInput,
44
+ ServerCallOutput,
45
+ { description: string; args: string[]; shorts: Record<string, string>; output: string }
46
+ > = createProcedure()
47
+ .path(["server", "call"])
48
+ .input(serverCallInputSchema)
49
+ .output(serverCallOutputSchema)
50
+ .meta({
51
+ description: "Call a procedure on a remote peer through an outbound connection",
52
+ args: ["path"],
53
+ shorts: { connectionId: "c", address: "a" },
54
+ output: "json",
55
+ })
56
+ .handler(async (input: ServerCallInput): Promise<ServerCallOutput> => {
57
+ // Normalize path: accept dot-separated string or array
58
+ const pathArray = typeof input.path === "string"
59
+ ? input.path.split(".")
60
+ : input.path;
61
+
62
+ // Resolve connection: by ID, or create one-shot from address
63
+ let connectionId: string;
64
+ let client: import("@procedures/client").Client;
65
+
66
+ if (input.connectionId) {
67
+ const connection = getConnection(input.connectionId);
68
+ if (!connection) {
69
+ throw new Error(
70
+ `Connection not found: ${input.connectionId}. Use 'server connect' first, or pass --address.`
71
+ );
72
+ }
73
+ connectionId = connection.id;
74
+ client = connection.client;
75
+ } else if (input.address) {
76
+ // Create a connection on-the-fly
77
+ const connection = await connect({ address: input.address });
78
+ connectionId = connection.id;
79
+ client = connection.client;
80
+ } else {
81
+ throw new Error("Either connectionId or address is required");
82
+ }
83
+
84
+ // Convert procedure path to Method format for client.call: the core mapping of every host
85
+ // (deep dive architecture review 3.3)
86
+ const method = pathToMethod(pathArray);
87
+
88
+ const result = await client.call(method, input.input ?? {});
89
+
90
+ return {
91
+ result,
92
+ connectionId,
93
+ path: pathArray,
94
+ };
95
+ })
96
+ .build();
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Procedure: server.connect
3
+ * Connects to a remote peer for bidirectional RPC
4
+ */
5
+
6
+ import { createProcedure, type Procedure } from "@procedures/client";
7
+ import { wrapSchema } from "../schema.js";
8
+ import { ServerConnectOutputSchema } from "../output-schemas.js";
9
+ import { serverConnectInputSchema } from "../input-schemas.js";
10
+ import type { ServerConnectInput, ServerConnectOutput } from "../types.js";
11
+ import { connect } from "../connection/index.js";
12
+
13
+ // Use pass-through schemas
14
+ const serverConnectOutputSchema = wrapSchema<ServerConnectOutput>(ServerConnectOutputSchema);
15
+
16
+ export const serverConnectProcedure: Procedure<
17
+ ServerConnectInput,
18
+ ServerConnectOutput,
19
+ { description: string }
20
+ > = createProcedure()
21
+ .path(["server", "connect"])
22
+ .input(serverConnectInputSchema)
23
+ .output(serverConnectOutputSchema)
24
+ .meta({ description: "Connect to a remote peer for bidirectional RPC", args: ["address"] })
25
+ .handler(async (input: ServerConnectInput) => {
26
+ const connectOptions: Parameters<typeof connect>[0] = {
27
+ address: input.address,
28
+ };
29
+ if (input.transport !== undefined) {
30
+ connectOptions.transport = input.transport;
31
+ }
32
+ if (input.timeout !== undefined) {
33
+ connectOptions.timeout = input.timeout;
34
+ }
35
+ const connection = await connect(connectOptions);
36
+
37
+ return {
38
+ connectionId: connection.id,
39
+ remotePeerId: connection.remote.remotePeerId,
40
+ remoteProcedures: connection.remote.remoteProcedures,
41
+ };
42
+ })
43
+ .build();
44
+
45
+ export type { ServerConnectInput, ServerConnectOutput };