@procedures/server 0.0.0-stage → 0.2.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +308 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +18 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +184 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/run.d.ts +42 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +176 -0
- package/dist/run.js.map +1 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -3
- package/src/cli.ts +19 -0
- package/src/config.ts +193 -0
- package/src/index.ts +11 -0
- package/src/run.ts +208 -0
- package/src/types.ts +32 -0
package/src/run.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The procedure server of the `server` CLI and of `mark serve` (roadmap 2.3, 3.1).
|
|
3
|
+
*
|
|
4
|
+
* The server builds a registry of its own from the modules of its `--procedures` packages (the
|
|
5
|
+
* side-effect-free `<package>/module` entry), with the `server.*` procedures, and serves it under
|
|
6
|
+
* the host policy of the core (`hostPolicy()`): the token, the `--expose` list and the browser
|
|
7
|
+
* checks. A package without a module entry still works: the server imports it and takes the
|
|
8
|
+
* procedures that the import added to the global registry.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import {
|
|
16
|
+
PROCEDURE_REGISTRY,
|
|
17
|
+
createRegistry,
|
|
18
|
+
hostPolicy,
|
|
19
|
+
pathToKey,
|
|
20
|
+
type AnyProcedure,
|
|
21
|
+
type HostPolicy,
|
|
22
|
+
type ProcedureModule,
|
|
23
|
+
type ProcedureRegistry,
|
|
24
|
+
} from "@procedures/client";
|
|
25
|
+
import { createPeer, type Peer, type PeerEndpoint } from "@procedures/peer";
|
|
26
|
+
import serverModule from "@procedures/peer/module";
|
|
27
|
+
import { parseConfig } from "./config.js";
|
|
28
|
+
import type { ServerConfig } from "./types.js";
|
|
29
|
+
|
|
30
|
+
/** A package name and the subpath after it: "@a/b/register" gives ["@a/b", "./register"]. */
|
|
31
|
+
function splitSpec(spec: string): { name: string; subpath: string } {
|
|
32
|
+
const parts = spec.split("/");
|
|
33
|
+
const count = spec.startsWith("@") ? 2 : 1;
|
|
34
|
+
const name = parts.slice(0, count).join("/");
|
|
35
|
+
const rest = parts.slice(count).join("/");
|
|
36
|
+
return { name, subpath: rest ? `./${rest}` : "." };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The file of an export of a package (`subpath`: "." or "./module"), from the `node_modules`
|
|
41
|
+
* folders above `from`. Undefined when no folder has the package or the export.
|
|
42
|
+
*/
|
|
43
|
+
function resolveExport(name: string, subpath: string, from: string): string | undefined {
|
|
44
|
+
let dir = resolve(from);
|
|
45
|
+
for (;;) {
|
|
46
|
+
const folder = join(dir, "node_modules", ...name.split("/"));
|
|
47
|
+
const manifestPath = join(folder, "package.json");
|
|
48
|
+
if (existsSync(manifestPath)) {
|
|
49
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { exports?: Record<string, unknown>; main?: string };
|
|
50
|
+
const entry = manifest.exports?.[subpath] ?? manifest.exports?.[`${subpath}.js`];
|
|
51
|
+
const target =
|
|
52
|
+
typeof entry === "string"
|
|
53
|
+
? entry
|
|
54
|
+
: ((entry as { import?: string; default?: string } | undefined)?.import ?? (entry as { default?: string } | undefined)?.default);
|
|
55
|
+
if (target) return join(folder, target);
|
|
56
|
+
if (subpath === "." && manifest.main) return join(folder, manifest.main);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const parent = dirname(dir);
|
|
60
|
+
if (parent === dir) return undefined;
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The URL to import for a package export, from the first folder that has it; else the bare specifier. */
|
|
66
|
+
function importTarget(name: string, subpath: string, folders: readonly string[]): string | undefined {
|
|
67
|
+
for (const from of folders) {
|
|
68
|
+
const file = resolveExport(name, subpath, from);
|
|
69
|
+
if (file && existsSync(file)) return pathToFileURL(file).href;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The module of a procedure package: its `module` entry, or else the procedures that importing
|
|
76
|
+
* the package adds to the global registry. A package resolves from the folders of `resolveFrom`
|
|
77
|
+
* (the current folder by default), then from this package. "pkg/register" (the form of the old
|
|
78
|
+
* examples) gives the module of "pkg". A file URL or a path is imported as it is.
|
|
79
|
+
*/
|
|
80
|
+
export async function loadProcedureModule(spec: string, resolveFrom: readonly string[] = [process.cwd()]): Promise<ProcedureModule> {
|
|
81
|
+
const isFile = spec.startsWith("file:") || spec.startsWith(".") || isAbsolute(spec);
|
|
82
|
+
if (!isFile) {
|
|
83
|
+
const { name, subpath } = splitSpec(spec);
|
|
84
|
+
const moduleUrl = importTarget(name, "./module", resolveFrom);
|
|
85
|
+
try {
|
|
86
|
+
const exports = (await import(moduleUrl ?? `${name}/module`)) as { default?: ProcedureModule };
|
|
87
|
+
if (exports.default && Array.isArray(exports.default.procedures)) return exports.default;
|
|
88
|
+
} catch {
|
|
89
|
+
// No module entry: the package registers on import
|
|
90
|
+
}
|
|
91
|
+
return importedModule(spec, importTarget(name, subpath, resolveFrom) ?? spec);
|
|
92
|
+
}
|
|
93
|
+
return importedModule(spec, spec.startsWith("file:") ? spec : pathToFileURL(resolve(spec)).href);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The procedures that importing `target` adds to the global registry, as a module. */
|
|
97
|
+
async function importedModule(name: string, target: string): Promise<ProcedureModule> {
|
|
98
|
+
const before = new Set(PROCEDURE_REGISTRY.getAll().map((procedure) => pathToKey(procedure.path)));
|
|
99
|
+
await import(target);
|
|
100
|
+
const added: AnyProcedure[] = PROCEDURE_REGISTRY.getAll().filter((procedure) => !before.has(pathToKey(procedure.path)));
|
|
101
|
+
return { name, procedures: added };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The server that `runServer()` started. */
|
|
105
|
+
export interface RunningServer {
|
|
106
|
+
peer: Peer;
|
|
107
|
+
registry: ProcedureRegistry;
|
|
108
|
+
policy: HostPolicy;
|
|
109
|
+
endpoints: PeerEndpoint[];
|
|
110
|
+
token: string | undefined;
|
|
111
|
+
/** True when the token is random, for this run */
|
|
112
|
+
generatedToken: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The token of a configuration: `--token`, else `SERVER_TOKEN`, else a random one. `--no-token`: none. */
|
|
116
|
+
function tokenOf(config: ServerConfig): { token: string | undefined; generated: boolean } {
|
|
117
|
+
const envToken = process.env["SERVER_TOKEN"] || undefined;
|
|
118
|
+
if (config.token === null) return { token: undefined, generated: false };
|
|
119
|
+
if (config.token !== undefined) return { token: config.token, generated: false };
|
|
120
|
+
if (envToken !== undefined) return { token: envToken, generated: false };
|
|
121
|
+
return { token: randomBytes(24).toString("hex"), generated: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The options of `startServer()` and `runServer()`. */
|
|
125
|
+
export interface ServerRunOptions {
|
|
126
|
+
/** The folders that a `--procedures` package resolves from, first to last (default: the current folder). */
|
|
127
|
+
resolveFrom?: readonly string[] | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** This function starts the server of a configuration. It returns when the transports listen. */
|
|
131
|
+
export async function startServer(
|
|
132
|
+
config: ServerConfig,
|
|
133
|
+
log: (line: string) => void = console.log,
|
|
134
|
+
options: ServerRunOptions = {}
|
|
135
|
+
): Promise<RunningServer> {
|
|
136
|
+
const modules: ProcedureModule[] = [serverModule];
|
|
137
|
+
for (const spec of config.procedures) {
|
|
138
|
+
if (config.verbose) log(` Loading: ${spec}`);
|
|
139
|
+
try {
|
|
140
|
+
modules.push(await loadProcedureModule(spec, options.resolveFrom ?? [process.cwd()]));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error(`Failed to load procedure package ${spec}: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const registry = createRegistry(...modules);
|
|
146
|
+
if (config.verbose) log(` Registered procedures: ${registry.getAll().length}`);
|
|
147
|
+
|
|
148
|
+
// A token by default: the server serves every procedure it loads, and any local program
|
|
149
|
+
// could call it. `--no-token` turns this off on purpose.
|
|
150
|
+
const { token, generated } = tokenOf(config);
|
|
151
|
+
const policy = hostPolicy({
|
|
152
|
+
...(config.expose.length > 0 ? { expose: config.expose } : {}),
|
|
153
|
+
...(token !== undefined ? { token } : {}),
|
|
154
|
+
});
|
|
155
|
+
const peer = await createPeer({
|
|
156
|
+
id: `peer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
157
|
+
transports: config.transports,
|
|
158
|
+
registry,
|
|
159
|
+
policy,
|
|
160
|
+
});
|
|
161
|
+
await peer.start();
|
|
162
|
+
return { peer, registry, policy, endpoints: peer.getEndpoints(), token, generatedToken: generated };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The `server` CLI: parse the options, start the server, print where it listens, and keep the
|
|
167
|
+
* process alive until a signal. `mark serve` calls it with its own arguments.
|
|
168
|
+
*/
|
|
169
|
+
export async function runServer(argv: string[], options: ServerRunOptions = {}): Promise<void> {
|
|
170
|
+
const config = parseConfig(argv);
|
|
171
|
+
console.log("Starting procedure server...");
|
|
172
|
+
if (config.verbose) {
|
|
173
|
+
console.log(" Procedures:", config.procedures.join(", ") || "(none specified)");
|
|
174
|
+
console.log(" Transports:", config.transports.map((t) => t.type).join(", "));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const running = await startServer(config, console.log, options);
|
|
178
|
+
|
|
179
|
+
console.log("\nServer started!");
|
|
180
|
+
console.log(` Server ID: ${running.peer.id}`);
|
|
181
|
+
console.log(` Procedures: ${running.peer.getServer().procedureCount}`);
|
|
182
|
+
console.log("\nEndpoints:");
|
|
183
|
+
for (const endpoint of running.endpoints) {
|
|
184
|
+
console.log(` [${endpoint.type}] ${endpoint.address}`);
|
|
185
|
+
}
|
|
186
|
+
if (running.token === undefined) {
|
|
187
|
+
console.log("\nNo token: any program on this computer can call the server.");
|
|
188
|
+
} else {
|
|
189
|
+
console.log(`\nToken${running.generatedToken ? " (random, for this run)" : ""}: ${running.token}`);
|
|
190
|
+
console.log(" Send it as 'Authorization: Bearer <token>', or as ?token=<token> for WebSocket.");
|
|
191
|
+
}
|
|
192
|
+
if (config.expose.length > 0) {
|
|
193
|
+
console.log(`Serves only: ${config.expose.join(", ")}`);
|
|
194
|
+
}
|
|
195
|
+
console.log("\nPress Ctrl+C to stop the server.");
|
|
196
|
+
|
|
197
|
+
const stop = (): void => {
|
|
198
|
+
console.log("\nShutting down...");
|
|
199
|
+
running.peer.stop().finally(() => process.exit(0));
|
|
200
|
+
};
|
|
201
|
+
process.on("SIGINT", stop);
|
|
202
|
+
process.on("SIGTERM", stop);
|
|
203
|
+
|
|
204
|
+
// Keep the process alive
|
|
205
|
+
await new Promise(() => {
|
|
206
|
+
// Never resolves - keeps process running
|
|
207
|
+
});
|
|
208
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Configuration Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface TransportConfig {
|
|
6
|
+
type: "http" | "websocket" | "local";
|
|
7
|
+
port?: number;
|
|
8
|
+
host?: string;
|
|
9
|
+
basePath?: string;
|
|
10
|
+
cors?: boolean;
|
|
11
|
+
corsOrigins?: string[];
|
|
12
|
+
path?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ServerConfig {
|
|
16
|
+
procedures: string[];
|
|
17
|
+
transports: TransportConfig[];
|
|
18
|
+
verbose: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The token that every request must send. `undefined`: the server makes a random token and
|
|
21
|
+
* prints it. `null` (`--no-token`): no token.
|
|
22
|
+
*/
|
|
23
|
+
token: string | null | undefined;
|
|
24
|
+
/** The procedures to serve (`--expose`): exact keys or `prefix.*`. Empty: every procedure. */
|
|
25
|
+
expose: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ServerCreateResult {
|
|
29
|
+
serverId: string;
|
|
30
|
+
endpoints: Array<{ type: string; address: string }>;
|
|
31
|
+
procedureCount: number;
|
|
32
|
+
}
|