@procedures/server 0.3.0 → 0.5.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 (52) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +44 -2
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/config.d.ts +6 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +53 -1
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts +7 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +8 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/module.d.ts +24 -0
  13. package/dist/module.d.ts.map +1 -0
  14. package/dist/module.js +29 -0
  15. package/dist/module.js.map +1 -0
  16. package/dist/procedures/server.start.d.ts +17 -0
  17. package/dist/procedures/server.start.d.ts.map +1 -0
  18. package/dist/procedures/server.start.js +183 -0
  19. package/dist/procedures/server.start.js.map +1 -0
  20. package/dist/procedures/server.status.d.ts +15 -0
  21. package/dist/procedures/server.status.d.ts.map +1 -0
  22. package/dist/procedures/server.status.js +118 -0
  23. package/dist/procedures/server.status.js.map +1 -0
  24. package/dist/procedures/server.stop.d.ts +14 -0
  25. package/dist/procedures/server.stop.d.ts.map +1 -0
  26. package/dist/procedures/server.stop.js +98 -0
  27. package/dist/procedures/server.stop.js.map +1 -0
  28. package/dist/register.d.ts +7 -0
  29. package/dist/register.d.ts.map +1 -0
  30. package/dist/register.js +9 -0
  31. package/dist/register.js.map +1 -0
  32. package/dist/run.d.ts +5 -0
  33. package/dist/run.d.ts.map +1 -1
  34. package/dist/run.js +48 -3
  35. package/dist/run.js.map +1 -1
  36. package/dist/schemas.d.ts +19 -0
  37. package/dist/schemas.d.ts.map +1 -0
  38. package/dist/schemas.js +48 -0
  39. package/dist/schemas.js.map +1 -0
  40. package/dist/types.d.ts +65 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +22 -7
  43. package/src/config.ts +52 -1
  44. package/src/index.ts +27 -2
  45. package/src/module.ts +33 -0
  46. package/src/procedures/server.start.ts +213 -0
  47. package/src/procedures/server.status.ts +141 -0
  48. package/src/procedures/server.stop.ts +123 -0
  49. package/src/register.ts +11 -0
  50. package/src/run.ts +52 -3
  51. package/src/schemas.ts +69 -0
  52. package/src/types.ts +83 -0
package/src/index.ts CHANGED
@@ -6,6 +6,31 @@
6
6
  * starts a server from a parsed configuration.
7
7
  */
8
8
 
9
- export { parseConfig } from "./config.js";
9
+ export { parseConfig, parseTopicPatterns } from "./config.js";
10
10
  export { runServer, startServer, loadProcedureModule, type RunningServer, type ServerRunOptions } from "./run.js";
11
- export type { ServerConfig, TransportConfig, ServerCreateResult } from "./types.js";
11
+ export type {
12
+ ServerConfig,
13
+ TransportConfig,
14
+ ServerCreateResult,
15
+ ServerStartInput,
16
+ ServerStartOutput,
17
+ ServerStopInput,
18
+ ServerStopOutput,
19
+ ServerStatusInput,
20
+ ServerStatusOutput,
21
+ } from "./types.js";
22
+
23
+ // server.start, server.stop and server.status (from @procedures/peer). The root registers
24
+ // nothing: import "@procedures/server/register", or give the module to createRegistry().
25
+ export * from "./module.js";
26
+ export { serverStartProcedure } from "./procedures/server.start.js";
27
+ export { serverStopProcedure } from "./procedures/server.stop.js";
28
+ export { serverStatusProcedure } from "./procedures/server.status.js";
29
+ export {
30
+ serverStartInputSchema,
31
+ serverStopInputSchema,
32
+ serverStatusInputSchema,
33
+ ServerStartOutputSchema,
34
+ ServerStopOutputSchema,
35
+ ServerStatusOutputSchema,
36
+ } from "./schemas.js";
package/src/module.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Module (no side effects): the procedures of this package as data, for `createRegistry()`.
3
+ *
4
+ * The package holds the host: the `server` CLI, `mark serve`, and the procedures that manage the
5
+ * background server of the `mark` CLI. `server.start`, `server.stop` and `server.status` came
6
+ * from `@procedures/peer`. Their names stay the same. The other `server.*` procedures
7
+ * (`server.create`, `server.connect` and the others) stay in the peer package.
8
+ */
9
+
10
+ import { defineModule, registerModules, PROCEDURE_REGISTRY, type AnyProcedure, type ProcedureModule, type ProcedureRegistry } from "@procedures/client";
11
+ import { serverStartProcedure } from "./procedures/server.start.js";
12
+ import { serverStopProcedure } from "./procedures/server.stop.js";
13
+ import { serverStatusProcedure } from "./procedures/server.status.js";
14
+
15
+ /** The procedures of the host: `server.start`, `server.stop` and `server.status`. */
16
+ export const hostProcedures: readonly AnyProcedure[] = [serverStartProcedure, serverStopProcedure, serverStatusProcedure];
17
+
18
+ /**
19
+ * The module of this package, for `createRegistry()`: its procedures as data. Importing this
20
+ * file registers nothing. (`register.ts` registers the same procedures in the global registry.)
21
+ */
22
+ export const serverModule: ProcedureModule = defineModule("@procedures/server", [...hostProcedures]);
23
+
24
+ export default serverModule;
25
+
26
+ /**
27
+ * This function registers the procedures of @procedures/server in a registry. The default is the
28
+ * global registry. Importing this file registers nothing: the `./register` entry starts this
29
+ * function (ARCHITECTURE-PROPOSALS-2026-10, P9).
30
+ */
31
+ export function registerServerProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
32
+ registerModules(registry, [serverModule]);
33
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Procedure: server.start
3
+ * Starts the CLI server as a background daemon
4
+ *
5
+ * The procedure came from `@procedures/peer`: it belongs to the host. The lockfiles stay in the
6
+ * peer package, because the peer of `mark --server` writes them.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
13
+ import { createProcedure, zodAdapter, type Effects, type Procedure } from "@procedures/client";
14
+ import { checkServer, readLockfileForPort } from "@procedures/peer/lockfile";
15
+ import { ServerStartOutputSchema, serverStartInputSchema } from "../schemas.js";
16
+ import type { ServerStartInput, ServerStartOutput } from "../types.js";
17
+
18
+ const MARK_DIR = path.join(os.homedir(), ".mark");
19
+ const SERVERS_DIR = path.join(MARK_DIR, "servers");
20
+ const LOG_PATH = path.join(MARK_DIR, "server.log");
21
+ const LOG_PREV_PATH = path.join(MARK_DIR, "server.log.1");
22
+
23
+ const serverStartOutputSchema = zodAdapter<ServerStartOutput>(ServerStartOutputSchema);
24
+
25
+
26
+ /**
27
+ * Check if process is alive
28
+ */
29
+ function isProcessAlive(pid: number): boolean {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Rotate log file
40
+ */
41
+ function rotateLog(): void {
42
+ try {
43
+ fs.mkdirSync(MARK_DIR, { recursive: true });
44
+ if (fs.existsSync(LOG_PATH)) {
45
+ fs.renameSync(LOG_PATH, LOG_PREV_PATH);
46
+ }
47
+ } catch {
48
+ // Ignore rotation errors
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Find the CLI entry point
54
+ */
55
+ function findCliPath(): string {
56
+ // First, try to use the currently running script if it's the CLI
57
+ const currentScript = process.argv[1];
58
+ if (currentScript) {
59
+ // Normalize path for cross-platform check
60
+ const normalized = currentScript.replace(/\\/g, "/");
61
+ // mark runs as dist/bin.js (its bin) or dist/cli.js
62
+ if (normalized.endsWith("/dist/bin.js") || normalized.endsWith("cli.js") || normalized.includes("cli/dist")) {
63
+ // Return absolute path
64
+ return path.resolve(currentScript);
65
+ }
66
+ }
67
+
68
+ // Try to find the mark CLI in node_modules or as a direct path
69
+ const candidates = [
70
+ path.join(process.cwd(), "node_modules", "@procedures", "mark", "dist", "cli.js"),
71
+ path.join(process.cwd(), "node_modules", ".bin", "mark"),
72
+ // Check parent directories for monorepo setups
73
+ path.join(process.cwd(), "..", "cli", "dist", "cli.js"),
74
+ // Direct check in current directory
75
+ path.join(process.cwd(), "dist", "cli.js"),
76
+ ];
77
+
78
+ // Check which candidate exists
79
+ for (const candidate of candidates) {
80
+ if (fs.existsSync(candidate)) {
81
+ return candidate;
82
+ }
83
+ }
84
+
85
+ // Fallback: try mark in PATH (will fail if not installed globally)
86
+ return "mark";
87
+ }
88
+
89
+ export const serverStartProcedure: Procedure<
90
+ ServerStartInput,
91
+ ServerStartOutput,
92
+ { description: string; effects: Effects; args: string[]; shorts: Record<string, string> }
93
+ > = createProcedure()
94
+ .path(["server", "start"])
95
+ .input(serverStartInputSchema)
96
+ .output(serverStartOutputSchema)
97
+ .meta({
98
+ description: "Start CLI server as background daemon",
99
+ // The daemon loads the procedure packages of the workspace. The procedure moves server.log
100
+ // over server.log.1.
101
+ effects: { reads: ["fs", "server"], writes: ["fs", "network", "process", "server"] },
102
+ args: [],
103
+ shorts: { port: "p", host: "h" },
104
+ })
105
+ .handler(async (input: ServerStartInput): Promise<ServerStartOutput> => {
106
+ const port = input.port ?? 3000;
107
+ // Loopback by default; pass host: "0.0.0.0" to expose on the LAN deliberately.
108
+ // See documentation/BUGS-2026-07.md (H19).
109
+ const host = input.host ?? "127.0.0.1";
110
+ const transport = input.transport ?? "http";
111
+
112
+ // Check if server is already running on this port
113
+ const existing = readLockfileForPort(port);
114
+ if (existing && (await checkServer(existing)) === "alive") {
115
+ return {
116
+ success: false,
117
+ message: `Server already running on port ${port} (PID ${existing.pid})`,
118
+ };
119
+ }
120
+
121
+ // Rotate log file
122
+ rotateLog();
123
+
124
+ // Ensure directories exist
125
+ fs.mkdirSync(SERVERS_DIR, { recursive: true });
126
+
127
+ // Open log file for writing
128
+ const logFd = fs.openSync(LOG_PATH, "a");
129
+
130
+ // Find CLI path
131
+ const cliPath = findCliPath();
132
+
133
+ // Build arguments
134
+ const args = ["--server", "--port", String(port), "--host", host];
135
+ if (transport !== "http") {
136
+ args.push("--transport", transport);
137
+ }
138
+
139
+ // Determine how to spawn
140
+ let spawnCmd: string;
141
+ let spawnArgs: string[];
142
+
143
+ if (cliPath === "mark" || cliPath.endsWith(".bin/mark") || cliPath.endsWith(".bin\\mark")) {
144
+ // Use mark command directly
145
+ spawnCmd = cliPath;
146
+ spawnArgs = args;
147
+ } else {
148
+ // Use node to run the JS file
149
+ spawnCmd = process.execPath;
150
+ spawnArgs = [cliPath, ...args];
151
+ }
152
+
153
+ // Spawn daemon process
154
+ const child = spawn(spawnCmd, spawnArgs, {
155
+ detached: true,
156
+ stdio: ["ignore", logFd, logFd],
157
+ env: { ...process.env },
158
+ cwd: process.cwd(),
159
+ // No console window on Windows (deep dive CLI-16)
160
+ windowsHide: true,
161
+ });
162
+
163
+ // Unref so parent can exit
164
+ child.unref();
165
+ fs.closeSync(logFd);
166
+
167
+ // Poll for lockfile (server takes ~4-8s to load ecosystem)
168
+ const maxWait = 15000;
169
+ const pollInterval = 500;
170
+ const startTime = Date.now();
171
+
172
+ while (Date.now() - startTime < maxWait) {
173
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
174
+
175
+ // Check if child died
176
+ if (child.pid && !isProcessAlive(child.pid)) {
177
+ break;
178
+ }
179
+
180
+ // Check for lockfile
181
+ const lockfile = readLockfileForPort(port);
182
+ if (lockfile && (await checkServer(lockfile)) === "alive") {
183
+ return {
184
+ success: true,
185
+ pid: lockfile.pid,
186
+ port: lockfile.port,
187
+ endpoint: lockfile.endpoint,
188
+ logFile: LOG_PATH,
189
+ message: `Server started (PID ${lockfile.pid})`,
190
+ };
191
+ }
192
+ }
193
+
194
+ // Timed out waiting for lockfile, but child may still be alive
195
+ if (child.pid && isProcessAlive(child.pid)) {
196
+ return {
197
+ success: true,
198
+ pid: child.pid,
199
+ port,
200
+ endpoint: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}/api`,
201
+ logFile: LOG_PATH,
202
+ message: `Server starting (PID ${child.pid}, still loading...)`,
203
+ };
204
+ }
205
+
206
+ return {
207
+ success: false,
208
+ message: `Failed to start server. Check ${LOG_PATH} for details.`,
209
+ };
210
+ })
211
+ .build();
212
+
213
+ export type { ServerStartInput, ServerStartOutput };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Procedure: server.status
3
+ * Gets the current CLI server status
4
+ *
5
+ * The procedure came from `@procedures/peer`: it belongs to the host.
6
+ */
7
+
8
+ import * as path from "node:path";
9
+ import * as os from "node:os";
10
+ import { createProcedure, zodAdapter, type Effects, type Procedure } from "@procedures/client";
11
+ import { checkServer, readAllLockfiles, readLockfileForPort, removeLockfileForPort, type LockfileData } from "@procedures/peer/lockfile";
12
+ import { ServerStatusOutputSchema, serverStatusInputSchema } from "../schemas.js";
13
+ import type { ServerStatusInput, ServerStatusOutput } from "../types.js";
14
+
15
+ const MARK_DIR = path.join(os.homedir(), ".mark");
16
+ const LOG_PATH = path.join(MARK_DIR, "server.log");
17
+
18
+ const serverStatusOutputSchema = zodAdapter<ServerStatusOutput>(ServerStatusOutputSchema);
19
+
20
+
21
+
22
+
23
+
24
+ /**
25
+ * Format uptime as human-readable string
26
+ */
27
+ function formatUptime(startedAt: string): string {
28
+ const start = new Date(startedAt).getTime();
29
+ const now = Date.now();
30
+ const diff = Math.floor((now - start) / 1000);
31
+
32
+ if (diff < 60) {
33
+ return `${diff}s`;
34
+ }
35
+ if (diff < 3600) {
36
+ const mins = Math.floor(diff / 60);
37
+ const secs = diff % 60;
38
+ return `${mins}m ${secs}s`;
39
+ }
40
+ if (diff < 86400) {
41
+ const hours = Math.floor(diff / 3600);
42
+ const mins = Math.floor((diff % 3600) / 60);
43
+ return `${hours}h ${mins}m`;
44
+ }
45
+ const days = Math.floor(diff / 86400);
46
+ const hours = Math.floor((diff % 86400) / 3600);
47
+ return `${days}d ${hours}h`;
48
+ }
49
+
50
+ export const serverStatusProcedure: Procedure<
51
+ ServerStatusInput,
52
+ ServerStatusOutput,
53
+ { description: string; effects: Effects; output: string }
54
+ > = createProcedure()
55
+ .path(["server", "status"])
56
+ .input(serverStatusInputSchema)
57
+ .output(serverStatusOutputSchema)
58
+ .meta({
59
+ description: "Get CLI server status",
60
+ // It deletes the lockfile of a server that does not answer
61
+ effects: { reads: ["fs", "server"], writes: ["fs"], idempotent: true },
62
+ args: [],
63
+ shorts: { port: "p" },
64
+ output: "text",
65
+ })
66
+ .handler(async (input: ServerStatusInput): Promise<ServerStatusOutput> => {
67
+ // Get target servers
68
+ let targets: LockfileData[];
69
+ if (input.port !== undefined) {
70
+ const lockfile = readLockfileForPort(input.port);
71
+ targets = lockfile ? [lockfile] : [];
72
+ } else {
73
+ targets = readAllLockfiles();
74
+ }
75
+
76
+ if (targets.length === 0) {
77
+ return {
78
+ running: false,
79
+ message: input.port
80
+ ? `No server found on port ${input.port}`
81
+ : "No servers running",
82
+ };
83
+ }
84
+
85
+ // For single server, return detailed info
86
+ if (targets.length === 1) {
87
+ const lockfile = targets[0]!;
88
+ const { pid, port, endpoint, startedAt, transport } = lockfile;
89
+
90
+ if ((await checkServer(lockfile)) !== "alive") {
91
+ removeLockfileForPort(port);
92
+ return {
93
+ running: false,
94
+ message: "Server is not running (cleaned up stale lockfile)",
95
+ };
96
+ }
97
+
98
+ const uptime = formatUptime(startedAt);
99
+ return {
100
+ running: true,
101
+ pid,
102
+ port,
103
+ endpoint,
104
+ transport,
105
+ startedAt,
106
+ uptime,
107
+ logFile: LOG_PATH,
108
+ message: `Server running (PID ${pid}, port ${port}, uptime ${uptime})`,
109
+ };
110
+ }
111
+
112
+ // Multiple servers - summarize
113
+ const lines: string[] = [];
114
+ let anyRunning = false;
115
+
116
+ for (const lockfile of targets) {
117
+ const { pid, port, endpoint, startedAt } = lockfile;
118
+ if ((await checkServer(lockfile)) !== "alive") {
119
+ removeLockfileForPort(port);
120
+ continue;
121
+ }
122
+ anyRunning = true;
123
+ const uptime = formatUptime(startedAt);
124
+ lines.push(` Port ${port}: PID ${pid}, uptime ${uptime}, ${endpoint}`);
125
+ }
126
+
127
+ if (!anyRunning) {
128
+ return {
129
+ running: false,
130
+ message: "No servers running (cleaned up stale lockfiles)",
131
+ };
132
+ }
133
+
134
+ return {
135
+ running: true,
136
+ message: `${lines.length} server(s) running:\n${lines.join("\n")}`,
137
+ };
138
+ })
139
+ .build();
140
+
141
+ export type { ServerStatusInput, ServerStatusOutput };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Procedure: server.stop
3
+ * Stops the running CLI server daemon
4
+ *
5
+ * The procedure came from `@procedures/peer`: it belongs to the host.
6
+ */
7
+
8
+ import { createProcedure, zodAdapter, type Effects, type Procedure } from "@procedures/client";
9
+ import { checkServer, readAllLockfiles, readLockfileForPort, removeLockfileForPort, type LockfileData } from "@procedures/peer/lockfile";
10
+ import { ServerStopOutputSchema, serverStopInputSchema } from "../schemas.js";
11
+ import type { ServerStopInput, ServerStopOutput } from "../types.js";
12
+
13
+
14
+ const serverStopOutputSchema = zodAdapter<ServerStopOutput>(ServerStopOutputSchema);
15
+
16
+
17
+
18
+
19
+
20
+ /**
21
+ * Kill process by PID
22
+ */
23
+ function killProcess(pid: number, signal: NodeJS.Signals = "SIGTERM"): boolean {
24
+ try {
25
+ process.kill(pid, signal);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export const serverStopProcedure: Procedure<
33
+ ServerStopInput,
34
+ ServerStopOutput,
35
+ { description: string; effects: Effects }
36
+ > = createProcedure()
37
+ .path(["server", "stop"])
38
+ .input(serverStopInputSchema)
39
+ .output(serverStopOutputSchema)
40
+ .meta({
41
+ description: "Stop running CLI server",
42
+ // It stops the servers of the lockfiles and deletes their lockfiles
43
+ effects: { reads: ["fs", "server"], writes: ["fs", "server"], idempotent: true },
44
+ args: [],
45
+ shorts: { port: "p" },
46
+ })
47
+ .handler(async (input: ServerStopInput): Promise<ServerStopOutput> => {
48
+ const force = input.force ?? false;
49
+ const signal: NodeJS.Signals = force ? "SIGKILL" : "SIGTERM";
50
+
51
+ // Get target servers
52
+ let targets: LockfileData[];
53
+ if (input.port !== undefined) {
54
+ const lockfile = readLockfileForPort(input.port);
55
+ targets = lockfile ? [lockfile] : [];
56
+ } else {
57
+ targets = readAllLockfiles();
58
+ }
59
+
60
+ if (targets.length === 0) {
61
+ return {
62
+ success: false,
63
+ message: input.port
64
+ ? `No server found on port ${input.port}`
65
+ : "No servers running",
66
+ };
67
+ }
68
+
69
+ const results: string[] = [];
70
+ let allSuccess = true;
71
+
72
+ for (const lockfile of targets) {
73
+ const { pid, port } = lockfile;
74
+
75
+ // Signal the PID only when the server answers as the peer of this lockfile: Windows reuses
76
+ // PIDs, and before, a stale lockfile made this kill an unrelated process (deep dive CLI-4)
77
+ const state = await checkServer(lockfile);
78
+ if (state !== "alive") {
79
+ removeLockfileForPort(port);
80
+ results.push(
81
+ state === "dead"
82
+ ? `Port ${port}: cleaned up stale lockfile`
83
+ : `Port ${port}: another process answers on this port; nothing stopped (stale lockfile removed)`
84
+ );
85
+ continue;
86
+ }
87
+
88
+ const sent = killProcess(pid, signal);
89
+ if (!sent) {
90
+ allSuccess = false;
91
+ results.push(`Port ${port}: failed to send ${signal} to PID ${pid}`);
92
+ continue;
93
+ }
94
+
95
+ // Wait for process to die
96
+ const maxWait = force ? 500 : 5000;
97
+ const startTime = Date.now();
98
+ let stopped = false;
99
+
100
+ while (Date.now() - startTime < maxWait) {
101
+ if ((await checkServer(lockfile, 300)) === "dead") {
102
+ removeLockfileForPort(port);
103
+ results.push(`Port ${port}: stopped (PID ${pid})`);
104
+ stopped = true;
105
+ break;
106
+ }
107
+ await new Promise((resolve) => setTimeout(resolve, 100));
108
+ }
109
+
110
+ if (!stopped) {
111
+ allSuccess = false;
112
+ results.push(`Port ${port}: PID ${pid} did not stop${force ? "" : " (try --force)"}`);
113
+ }
114
+ }
115
+
116
+ return {
117
+ success: allSuccess,
118
+ message: results.join("\n"),
119
+ };
120
+ })
121
+ .build();
122
+
123
+ export type { ServerStopInput, ServerStopOutput };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Importing this file registers the procedures of @procedures/server in the global registry:
3
+ * `import "@procedures/server/register"`. The package root and the `./module` entry register
4
+ * nothing (ARCHITECTURE-PROPOSALS-2026-10, P9).
5
+ */
6
+
7
+ import { registerServerProcedures } from "./module.js";
8
+
9
+ export * from "./module.js";
10
+
11
+ registerServerProcedures();
package/src/run.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  * the host policy of the core (`hostPolicy()`): the token, the `--expose` list and the browser
7
7
  * checks. A package without a module entry still works: the server imports it and takes the
8
8
  * procedures that the import added to the global registry.
9
+ *
10
+ * The server always serves `events.subscribe` (`documentation/EVENTS.md`). Its topic rule is the
11
+ * `--events` patterns, and no topic without them. `--fs-root` confines the `fs.*` procedures.
9
12
  */
10
13
 
11
14
  import { randomBytes } from "node:crypto";
@@ -23,8 +26,9 @@ import {
23
26
  type ProcedureRegistry,
24
27
  } from "@procedures/client";
25
28
  import { createPeer, type Peer, type PeerEndpoint } from "@procedures/peer";
26
- import serverModule from "@procedures/peer/module";
29
+ import peerModule from "@procedures/peer/module";
27
30
  import { parseConfig } from "./config.js";
31
+ import { serverModule } from "./module.js";
28
32
  import type { ServerConfig } from "./types.js";
29
33
  import { seconds } from "@procedures/client";
30
34
 
@@ -102,6 +106,26 @@ async function importedModule(name: string, target: string): Promise<ProcedureMo
102
106
  return { name, procedures: added };
103
107
  }
104
108
 
109
+ /** The function of `@procedures/fs` that confines the `fs.*` procedures of a registry to a root. */
110
+ type ConfineFs = (registry: ProcedureRegistry, root: string) => { root: string; procedures: string[] };
111
+
112
+ /**
113
+ * This function confines the `fs.*` procedures of the registry to the root of `--fs-root`, with
114
+ * `confineFsProcedures()` of `@procedures/fs` (roadmap R33). The package resolves from the
115
+ * folders of `resolveFrom`, then from this package. Without it, the server does not start.
116
+ */
117
+ async function confineFs(registry: ProcedureRegistry, root: string, resolveFrom: readonly string[]): Promise<{ root: string; procedures: string[] }> {
118
+ const target = importTarget("@procedures/fs", "./module", resolveFrom) ?? "@procedures/fs/module";
119
+ let confine: ConfineFs | undefined;
120
+ try {
121
+ confine = ((await import(target)) as { confineFsProcedures?: ConfineFs }).confineFsProcedures;
122
+ } catch (error) {
123
+ throw new Error(`--fs-root needs the package @procedures/fs: ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ if (typeof confine !== "function") throw new Error("--fs-root needs @procedures/fs 0.5.0 or later (confineFsProcedures)");
126
+ return confine(registry, root);
127
+ }
128
+
105
129
  /** The server that `runServer()` started. */
106
130
  export interface RunningServer {
107
131
  peer: Peer;
@@ -111,6 +135,8 @@ export interface RunningServer {
111
135
  token: string | undefined;
112
136
  /** True when the token is random, for this run */
113
137
  generatedToken: boolean;
138
+ /** The absolute path of the root of the `fs.*` procedures (`--fs-root`), or undefined. */
139
+ fsRoot?: string | undefined;
114
140
  }
115
141
 
116
142
  /** The token of a configuration: `--token`, else `SERVER_TOKEN`, else a random one. `--no-token`: none. */
@@ -134,7 +160,9 @@ export async function startServer(
134
160
  log: (line: string) => void = console.log,
135
161
  options: ServerRunOptions = {}
136
162
  ): Promise<RunningServer> {
137
- const modules: ProcedureModule[] = [serverModule];
163
+ // The server.* procedures: those of the peer package (server.create, server.call and the
164
+ // others), and server.start, server.stop and server.status of this package
165
+ const modules: ProcedureModule[] = [peerModule, serverModule];
138
166
  for (const spec of config.procedures) {
139
167
  if (config.verbose) log(` Loading: ${spec}`);
140
168
  try {
@@ -146,12 +174,20 @@ export async function startServer(
146
174
  const registry = createRegistry(...modules);
147
175
  if (config.verbose) log(` Registered procedures: ${registry.getAll().length}`);
148
176
 
177
+ // --fs-root: each fs.* procedure of the registry confines its paths, also for a nested call
178
+ const confined = config.fsRoot === undefined ? undefined : await confineFs(registry, config.fsRoot, options.resolveFrom ?? [process.cwd()]);
179
+ if (confined && config.verbose) log(` Confined to ${confined.root}: ${confined.procedures.length} fs procedures`);
180
+
149
181
  // A token by default: the server serves every procedure it loads, and any local program
150
182
  // could call it. `--no-token` turns this off on purpose.
151
183
  const { token, generated } = tokenOf(config);
152
184
  const policy = hostPolicy({
153
185
  ...(config.expose.length > 0 ? { expose: config.expose } : {}),
154
186
  ...(token !== undefined ? { token } : {}),
187
+ // The remote events (EVENTS.md): the server always pins events.subscribe. Without --events,
188
+ // the topic rule is empty, so each subscription gets TOPIC_NOT_FOUND. The bus is the global
189
+ // bus, which is also the ctx.bus of the procedures.
190
+ events: { topics: config.events ?? [] },
155
191
  });
156
192
  const peer = await createPeer({
157
193
  id: `peer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -160,7 +196,15 @@ export async function startServer(
160
196
  policy,
161
197
  });
162
198
  await peer.start();
163
- return { peer, registry, policy, endpoints: peer.getEndpoints(), token, generatedToken: generated };
199
+ return {
200
+ peer,
201
+ registry,
202
+ policy,
203
+ endpoints: peer.getEndpoints(),
204
+ token,
205
+ generatedToken: generated,
206
+ ...(confined ? { fsRoot: confined.root } : {}),
207
+ };
164
208
  }
165
209
 
166
210
  /**
@@ -193,6 +237,11 @@ export async function runServer(argv: string[], options: ServerRunOptions = {}):
193
237
  if (config.expose.length > 0) {
194
238
  console.log(`Serves only: ${config.expose.join(", ")}`);
195
239
  }
240
+ const events = config.events ?? [];
241
+ console.log(events.length > 0 ? `Event topics (events.subscribe): ${events.join(", ")}` : "Event topics: none (--events adds them)");
242
+ if (running.fsRoot !== undefined) {
243
+ console.log(`File root: ${running.fsRoot} (fs.* refuses each path outside it)`);
244
+ }
196
245
  console.log("\nPress Ctrl+C to stop the server.");
197
246
 
198
247
  // The peer stops before the process exits, with a limit of 10 seconds. A failed stop exits