@streamotter/cli 0.1.0-rc.1
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/LICENSE +21 -0
- package/README.md +104 -0
- package/bin/streamotter.js +2 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +306 -0
- package/dist/cli.js.map +1 -0
- package/dist/generate.d.ts +18 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +171 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +13 -0
- package/dist/main.js.map +1 -0
- package/dist/templates.d.ts +6 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +160 -0
- package/dist/templates.js.map +1 -0
- package/package.json +56 -0
- package/src/cli.ts +302 -0
- package/src/generate.ts +181 -0
- package/src/index.ts +3 -0
- package/src/main.ts +14 -0
- package/src/templates.ts +163 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import {
|
|
8
|
+
canonicalJsonPretty, validateProjectConfig,
|
|
9
|
+
type ConfigIssue, type DevelopmentOptions, type HandlerRegistry, type Json, type ProjectConfig
|
|
10
|
+
} from "@streamotter/contracts";
|
|
11
|
+
import { createGateway, type Gateway, type GatewayLogger } from "@streamotter/gateway";
|
|
12
|
+
import { startManagementServer } from "@streamotter/gateway/management";
|
|
13
|
+
import { getGatewayInternals } from "@streamotter/gateway/internals";
|
|
14
|
+
import { fingerprint, generateFiles, GENERATED_MARKER } from "./generate.ts";
|
|
15
|
+
import { scaffoldFiles } from "./templates.ts";
|
|
16
|
+
|
|
17
|
+
export const EXIT = { ok: 0, runtime: 1, invalid: 2 } as const;
|
|
18
|
+
|
|
19
|
+
export interface CliIO {
|
|
20
|
+
out(line: string): void;
|
|
21
|
+
err(line: string): void;
|
|
22
|
+
/** Resolves when the process should shut down (SIGINT/SIGTERM). */
|
|
23
|
+
shutdownSignal: Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class CliError extends Error {
|
|
27
|
+
readonly exitCode: number;
|
|
28
|
+
|
|
29
|
+
constructor(exitCode: number, message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.exitCode = exitCode;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const USAGE = `Usage:
|
|
36
|
+
streamotter init <directory>
|
|
37
|
+
streamotter validate --config <path>
|
|
38
|
+
streamotter generate --config <path> --out <directory>
|
|
39
|
+
streamotter dev --config <path> --handlers <module> [--management-port <port>]
|
|
40
|
+
streamotter start --config <path> --handlers <module>`;
|
|
41
|
+
|
|
42
|
+
function formatIssues(issues: readonly ConfigIssue[]): string {
|
|
43
|
+
return issues.map(issue => ` ${issue.path || "/"} ${issue.code}: ${issue.message}`).join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function loadConfig(path: string | undefined): Promise<{ config: ProjectConfig; path: string }> {
|
|
47
|
+
if (path === undefined) throw new CliError(EXIT.invalid, "--config <path> is required.");
|
|
48
|
+
const absolute = resolve(path);
|
|
49
|
+
let text: string;
|
|
50
|
+
try {
|
|
51
|
+
text = await readFile(absolute, "utf8");
|
|
52
|
+
} catch {
|
|
53
|
+
throw new CliError(EXIT.invalid, `Cannot read configuration file ${path}.`);
|
|
54
|
+
}
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(text);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new CliError(EXIT.invalid, `${path} is not valid JSON: ${(error as Error).message}`);
|
|
60
|
+
}
|
|
61
|
+
const { valid, issues } = validateProjectConfig(parsed);
|
|
62
|
+
if (!valid) throw new CliError(EXIT.invalid, `${path} is invalid (${issues.length} issue${issues.length === 1 ? "" : "s"}):\n${formatIssues(issues)}`);
|
|
63
|
+
return { config: parsed as ProjectConfig, path: absolute };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadHandlers(path: string | undefined): Promise<{ handlers: HandlerRegistry<never>; development: DevelopmentOptions | undefined }> {
|
|
67
|
+
if (path === undefined) throw new CliError(EXIT.invalid, "--handlers <module> is required.");
|
|
68
|
+
const absolute = resolve(path);
|
|
69
|
+
const extension = extname(absolute);
|
|
70
|
+
if ([".ts", ".tsx", ".mts", ".cts"].includes(extension)) {
|
|
71
|
+
throw new CliError(EXIT.invalid, `${path} is TypeScript. StreamOtter loads compiled JavaScript modules; compile your handlers and pass the .js output.`);
|
|
72
|
+
}
|
|
73
|
+
if (!existsSync(absolute)) throw new CliError(EXIT.invalid, `Handler module ${path} does not exist.`);
|
|
74
|
+
let module: Record<string, unknown>;
|
|
75
|
+
try {
|
|
76
|
+
module = await import(pathToFileURL(absolute).href) as Record<string, unknown>;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new CliError(EXIT.runtime, `Failed to load handler module ${path}: ${(error as Error).message}`);
|
|
79
|
+
}
|
|
80
|
+
if (typeof module["handlers"] !== "object" || module["handlers"] === null) {
|
|
81
|
+
throw new CliError(EXIT.invalid, `${path} must export \`handlers\` (a HandlerRegistry).`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
handlers: module["handlers"] as HandlerRegistry<never>,
|
|
85
|
+
development: module["development"] as DevelopmentOptions | undefined
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cliLogger(io: CliIO): GatewayLogger {
|
|
90
|
+
const line = (level: string, message: string, fields?: Readonly<Record<string, Json>>) =>
|
|
91
|
+
`${new Date().toISOString()} ${level.padEnd(5)} ${message}${fields !== undefined && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : ""}`;
|
|
92
|
+
return {
|
|
93
|
+
info: (message, fields) => io.out(line("info", message, fields)),
|
|
94
|
+
warn: (message, fields) => io.err(line("warn", message, fields)),
|
|
95
|
+
error: (message, fields) => io.err(line("error", message, fields))
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function workbenchDirectory(): string | null {
|
|
100
|
+
try {
|
|
101
|
+
const require = createRequire(import.meta.url);
|
|
102
|
+
const manifest = require.resolve("@streamotter/workbench/package.json");
|
|
103
|
+
const dir = join(dirname(manifest), "dist");
|
|
104
|
+
return existsSync(join(dir, "index.html")) ? dir : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function reportStartupFailure(io: CliIO, gateway: Gateway, error: unknown): Promise<never> {
|
|
111
|
+
io.err(`Gateway startup failed: ${(error as Error).message}`);
|
|
112
|
+
try {
|
|
113
|
+
for (const { sourceId, steps } of await getGatewayInternals(gateway).checkAllSources()) {
|
|
114
|
+
io.err(`Source "${sourceId}" diagnostics:`);
|
|
115
|
+
for (const step of steps) io.err(` ${step.outcome === "ok" ? "✓" : step.outcome === "failed" ? "✗" : "-"} ${step.stage.padEnd(12)} ${step.message}`);
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Diagnostics are best effort.
|
|
119
|
+
}
|
|
120
|
+
await gateway.stop({ timeoutMs: 2_000 }).catch(() => undefined);
|
|
121
|
+
const code = (error as { code?: string }).code;
|
|
122
|
+
throw new CliError(code === "CONFIG_INVALID" ? EXIT.invalid : EXIT.runtime, "The gateway did not start.");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runUntilSignal(io: CliIO, gateway: Gateway): Promise<number> {
|
|
126
|
+
const signal = await io.shutdownSignal;
|
|
127
|
+
io.out(`Received ${signal}; shutting down gracefully.`);
|
|
128
|
+
await gateway.stop({ timeoutMs: 10_000 });
|
|
129
|
+
return EXIT.ok;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function commandInit(positionals: string[], io: CliIO): Promise<number> {
|
|
133
|
+
const directory = positionals[0];
|
|
134
|
+
if (directory === undefined || positionals.length > 1) throw new CliError(EXIT.invalid, "Usage: streamotter init <directory>");
|
|
135
|
+
const target = resolve(directory);
|
|
136
|
+
if (existsSync(target) && !(await stat(target)).isDirectory()) throw new CliError(EXIT.invalid, `${directory} exists and is not a directory.`);
|
|
137
|
+
const projectId = basename(target).replace(/[^A-Za-z0-9_-]/g, "-").replace(/^[^A-Za-z]+/, "") || "streamotter-app";
|
|
138
|
+
const files = scaffoldFiles(projectId.slice(0, 64));
|
|
139
|
+
const conflicts = files.filter(file => existsSync(join(target, file.path))).map(file => file.path);
|
|
140
|
+
if (conflicts.length > 0) throw new CliError(EXIT.invalid, `Refusing to overwrite existing files: ${conflicts.join(", ")}`);
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
await mkdir(dirname(join(target, file.path)), { recursive: true });
|
|
143
|
+
await writeFile(join(target, file.path), file.content, { flag: "wx" });
|
|
144
|
+
io.out(` created ${join(directory, file.path)}`);
|
|
145
|
+
}
|
|
146
|
+
io.out(`\nNext:\n cd ${directory}\n streamotter dev --config streamotter.json --handlers server/handlers.mjs`);
|
|
147
|
+
return EXIT.ok;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function commandValidate(values: Record<string, unknown>, io: CliIO): Promise<number> {
|
|
151
|
+
const { config, path } = await loadConfig(values["config"] as string | undefined);
|
|
152
|
+
io.out(`${path} is valid.`);
|
|
153
|
+
io.out(`Fingerprint: sha256:${fingerprint(config)}`);
|
|
154
|
+
if (canonicalJsonPretty(config) !== await readFile(path, "utf8")) {
|
|
155
|
+
io.out("Note: the file is not in canonical form (workbench exports use sorted keys and two-space indentation); the fingerprint is unaffected.");
|
|
156
|
+
}
|
|
157
|
+
return EXIT.ok;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function commandGenerate(values: Record<string, unknown>, io: CliIO): Promise<number> {
|
|
161
|
+
const { config } = await loadConfig(values["config"] as string | undefined);
|
|
162
|
+
const out = values["out"] as string | undefined;
|
|
163
|
+
if (out === undefined) throw new CliError(EXIT.invalid, "--out <directory> is required.");
|
|
164
|
+
const target = resolve(out);
|
|
165
|
+
const files = generateFiles(config);
|
|
166
|
+
const blocked: string[] = [];
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const path = join(target, file.path);
|
|
169
|
+
if (existsSync(path) && !(await readFile(path, "utf8")).startsWith(GENERATED_MARKER)) blocked.push(file.path);
|
|
170
|
+
}
|
|
171
|
+
if (blocked.length > 0) {
|
|
172
|
+
throw new CliError(EXIT.invalid, `Refusing to overwrite files that were not created by the generator: ${blocked.join(", ")}`);
|
|
173
|
+
}
|
|
174
|
+
await mkdir(target, { recursive: true });
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
await writeFile(join(target, file.path), file.content);
|
|
177
|
+
io.out(` wrote ${join(out, file.path)}`);
|
|
178
|
+
}
|
|
179
|
+
return EXIT.ok;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function commandDev(values: Record<string, unknown>, io: CliIO): Promise<number> {
|
|
183
|
+
const { config, path } = await loadConfig(values["config"] as string | undefined);
|
|
184
|
+
const { handlers, development } = await loadHandlers(values["handlers"] as string | undefined);
|
|
185
|
+
const managementPort = values["management-port"] === undefined ? 7401 : Number(values["management-port"]);
|
|
186
|
+
if (!Number.isInteger(managementPort) || managementPort < 0 || managementPort > 65_535) throw new CliError(EXIT.invalid, "--management-port must be a port number.");
|
|
187
|
+
let gateway: Gateway;
|
|
188
|
+
try {
|
|
189
|
+
gateway = createGateway({
|
|
190
|
+
config, handlers, mode: "development", configDir: dirname(path), logger: cliLogger(io),
|
|
191
|
+
...(development === undefined ? {} : { development })
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
throw new CliError(EXIT.invalid, (error as Error).message);
|
|
195
|
+
}
|
|
196
|
+
let address: { origin: string; path: string };
|
|
197
|
+
try {
|
|
198
|
+
address = await gateway.start();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return reportStartupFailure(io, gateway, error);
|
|
201
|
+
}
|
|
202
|
+
const workbenchDir = workbenchDirectory();
|
|
203
|
+
let management;
|
|
204
|
+
try {
|
|
205
|
+
management = await startManagementServer({ gateway, port: managementPort, workbenchDir });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
await gateway.stop();
|
|
208
|
+
throw new CliError(EXIT.runtime, `The management server could not start: ${(error as Error).message}`);
|
|
209
|
+
}
|
|
210
|
+
const internals = getGatewayInternals(gateway);
|
|
211
|
+
io.out("");
|
|
212
|
+
io.out("StreamOtter development gateway");
|
|
213
|
+
io.out(` Gateway ${address.origin} (Socket.IO path ${address.path})`);
|
|
214
|
+
io.out(` Workbench ${workbenchDir === null ? "(not built; run pnpm build)" : `${management.origin}/`}`);
|
|
215
|
+
io.out(` Management ${management.origin}/management/v1 (local only)`);
|
|
216
|
+
io.out(` Token ${management.token}`);
|
|
217
|
+
io.out(` Config ${path} sha256:${internals.fingerprint.slice(0, 16)}…`);
|
|
218
|
+
io.out(` Sources ${internals.sources().map(source => `${source.sourceId} (${source.kind}, ${source.status})`).join(", ")}`);
|
|
219
|
+
const principals = development === undefined ? [] : Object.keys(development.principals ?? {});
|
|
220
|
+
io.out(` Principals ${principals.length === 0 ? "(none registered)" : principals.join(", ")}`);
|
|
221
|
+
io.out("");
|
|
222
|
+
io.out("Enter the token in the workbench. It is valid only for this run. Configuration edits in the");
|
|
223
|
+
io.out("workbench are candidates: export them and restart this command to apply. Press Ctrl+C to stop.");
|
|
224
|
+
return runUntilSignal(io, gateway);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function commandStart(values: Record<string, unknown>, io: CliIO): Promise<number> {
|
|
228
|
+
const { config, path } = await loadConfig(values["config"] as string | undefined);
|
|
229
|
+
const { handlers } = await loadHandlers(values["handlers"] as string | undefined);
|
|
230
|
+
let gateway: Gateway;
|
|
231
|
+
try {
|
|
232
|
+
// The module's development export is deliberately ignored in production.
|
|
233
|
+
gateway = createGateway({ config, handlers, mode: "production", configDir: dirname(path), logger: cliLogger(io) });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new CliError(EXIT.invalid, (error as Error).message);
|
|
236
|
+
}
|
|
237
|
+
let address: { origin: string; path: string };
|
|
238
|
+
try {
|
|
239
|
+
address = await gateway.start();
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return reportStartupFailure(io, gateway, error);
|
|
242
|
+
}
|
|
243
|
+
io.out(`StreamOtter gateway (production) listening on ${address.origin} path ${address.path}. No management or development endpoints are exposed.`);
|
|
244
|
+
return runUntilSignal(io, gateway);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Runs the CLI and returns its exit code: 0 success, 2 invalid input/configuration, 1 startup/runtime failure. */
|
|
248
|
+
export async function runCli(argv: readonly string[], io: CliIO): Promise<number> {
|
|
249
|
+
const [command, ...rest] = argv;
|
|
250
|
+
if (command === undefined || command === "--help" || command === "-h" || command === "help") {
|
|
251
|
+
io.out(USAGE);
|
|
252
|
+
return command === undefined ? EXIT.invalid : EXIT.ok;
|
|
253
|
+
}
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = parseArgs({
|
|
257
|
+
args: [...rest],
|
|
258
|
+
allowPositionals: true,
|
|
259
|
+
strict: true,
|
|
260
|
+
options: {
|
|
261
|
+
config: { type: "string" },
|
|
262
|
+
handlers: { type: "string" },
|
|
263
|
+
out: { type: "string" },
|
|
264
|
+
"management-port": { type: "string" }
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
io.err(`${(error as Error).message}\n\n${USAGE}`);
|
|
269
|
+
return EXIT.invalid;
|
|
270
|
+
}
|
|
271
|
+
const { values, positionals } = parsed;
|
|
272
|
+
const allowed: Record<string, readonly string[]> = {
|
|
273
|
+
init: [], validate: ["config"], generate: ["config", "out"], dev: ["config", "handlers", "management-port"], start: ["config", "handlers"]
|
|
274
|
+
};
|
|
275
|
+
const permitted = allowed[command];
|
|
276
|
+
if (permitted === undefined) {
|
|
277
|
+
io.err(`Unknown command "${command}".\n\n${USAGE}`);
|
|
278
|
+
return EXIT.invalid;
|
|
279
|
+
}
|
|
280
|
+
const extra = Object.keys(values).filter(key => !permitted.includes(key));
|
|
281
|
+
if (extra.length > 0 || (command !== "init" && positionals.length > 0)) {
|
|
282
|
+
io.err(`Unexpected arguments for ${command}: ${[...extra.map(key => `--${key}`), ...(command === "init" ? [] : positionals)].join(" ")}\n\n${USAGE}`);
|
|
283
|
+
return EXIT.invalid;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
switch (command) {
|
|
287
|
+
case "init": return await commandInit(positionals, io);
|
|
288
|
+
case "validate": return await commandValidate(values, io);
|
|
289
|
+
case "generate": return await commandGenerate(values, io);
|
|
290
|
+
case "dev": return await commandDev(values, io);
|
|
291
|
+
case "start": return await commandStart(values, io);
|
|
292
|
+
default: return EXIT.invalid;
|
|
293
|
+
}
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error instanceof CliError) {
|
|
296
|
+
io.err(error.message);
|
|
297
|
+
return error.exitCode;
|
|
298
|
+
}
|
|
299
|
+
io.err(`Unexpected failure: ${(error as Error).stack ?? String(error)}`);
|
|
300
|
+
return EXIT.runtime;
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalJson, type ProjectConfig, type Schema } from "@streamotter/contracts";
|
|
3
|
+
|
|
4
|
+
export const GENERATED_MARKER = "// @generated by streamotter generate";
|
|
5
|
+
|
|
6
|
+
const RESERVED = new Set([
|
|
7
|
+
"AppChannels", "ChannelContract", "Client", "Subscription", "Array", "Boolean", "Date", "Error", "Function", "JSON", "Map",
|
|
8
|
+
"Math", "Number", "Object", "Promise", "Record", "Set", "String", "Symbol"
|
|
9
|
+
]);
|
|
10
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
11
|
+
|
|
12
|
+
export function fingerprint(config: unknown): string {
|
|
13
|
+
return createHash("sha256").update(canonicalJson(config)).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function pascalCase(id: string): string {
|
|
17
|
+
return id.split(/[-_]+/).filter(Boolean).map(part => part[0]!.toUpperCase() + part.slice(1)).join("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Maps schema IDs to unique, valid, non-shadowing TypeScript type names. */
|
|
21
|
+
export function typeNames(schemaIds: readonly string[]): Map<string, string> {
|
|
22
|
+
const names = new Map<string, string>();
|
|
23
|
+
const used = new Set<string>();
|
|
24
|
+
for (const id of schemaIds) {
|
|
25
|
+
let name = pascalCase(id);
|
|
26
|
+
if (RESERVED.has(name)) name = `${name}Schema`;
|
|
27
|
+
let candidate = name;
|
|
28
|
+
for (let suffix = 2; used.has(candidate); suffix++) candidate = `${name}${suffix}`;
|
|
29
|
+
used.add(candidate);
|
|
30
|
+
names.set(id, candidate);
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function propertyKey(name: string): string {
|
|
36
|
+
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function describe(schema: Schema): string | null {
|
|
40
|
+
switch (schema.type) {
|
|
41
|
+
case "string": {
|
|
42
|
+
const parts = [];
|
|
43
|
+
if (schema.minLength !== undefined) parts.push(`minLength ${schema.minLength}`);
|
|
44
|
+
if (schema.maxLength !== undefined) parts.push(`maxLength ${schema.maxLength}`);
|
|
45
|
+
return parts.length > 0 ? parts.join(", ") : null;
|
|
46
|
+
}
|
|
47
|
+
case "number":
|
|
48
|
+
case "integer": {
|
|
49
|
+
const parts: string[] = [schema.type];
|
|
50
|
+
if (schema.minimum !== undefined) parts.push(`minimum ${schema.minimum}`);
|
|
51
|
+
if (schema.maximum !== undefined) parts.push(`maximum ${schema.maximum}`);
|
|
52
|
+
return schema.type === "integer" || parts.length > 1 ? parts.join(", ") : null;
|
|
53
|
+
}
|
|
54
|
+
case "array":
|
|
55
|
+
return `maxItems ${schema.maxItems}`;
|
|
56
|
+
default:
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Renders a schema as a TypeScript type expression (type aliases, never interfaces, so values stay assignable to Json). */
|
|
62
|
+
export function renderType(schema: Schema, indent = ""): string {
|
|
63
|
+
switch (schema.type) {
|
|
64
|
+
case "string":
|
|
65
|
+
return schema.enum !== undefined ? schema.enum.map(value => JSON.stringify(value)).join(" | ") : "string";
|
|
66
|
+
case "number":
|
|
67
|
+
case "integer":
|
|
68
|
+
return "number";
|
|
69
|
+
case "boolean":
|
|
70
|
+
return "boolean";
|
|
71
|
+
case "null":
|
|
72
|
+
return "null";
|
|
73
|
+
case "array": {
|
|
74
|
+
const item = renderType(schema.items, indent);
|
|
75
|
+
return /[|&]/.test(item) && !item.startsWith("{") ? `(${item})[]` : `${item}[]`;
|
|
76
|
+
}
|
|
77
|
+
case "object": {
|
|
78
|
+
const entries = Object.entries(schema.properties);
|
|
79
|
+
if (entries.length === 0) return "Record<string, never>";
|
|
80
|
+
const inner = `${indent} `;
|
|
81
|
+
const lines = entries.map(([name, child]) => {
|
|
82
|
+
const note = describe(child);
|
|
83
|
+
const comment = note === null ? "" : `${inner}/** ${note} */\n`;
|
|
84
|
+
const optional = schema.required.includes(name) ? "" : "?";
|
|
85
|
+
return `${comment}${inner}${propertyKey(name)}${optional}: ${renderType(child, inner)};`;
|
|
86
|
+
});
|
|
87
|
+
return `{\n${lines.join("\n")}\n${indent}}`;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
return "never";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GeneratedFile { path: string; content: string }
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generates the application channel contract (AppChannels with each channel's
|
|
98
|
+
* parameter type, payload type, and literal version) plus a vanilla TypeScript
|
|
99
|
+
* integration example. Numeric ranges and string lengths remain runtime checks.
|
|
100
|
+
*/
|
|
101
|
+
export function generateFiles(config: ProjectConfig): GeneratedFile[] {
|
|
102
|
+
const fp = fingerprint(config);
|
|
103
|
+
const header = `${GENERATED_MARKER} (config sha256:${fp}). Do not edit; rerun the generator.\n`;
|
|
104
|
+
const names = typeNames(Object.keys(config.schemas));
|
|
105
|
+
const schemaTypes = Object.entries(config.schemas).map(([id, schema]) =>
|
|
106
|
+
`/** Schema "${id}". */\nexport type ${names.get(id)} = ${renderType(schema)};`
|
|
107
|
+
);
|
|
108
|
+
const channels = Object.entries(config.channels).map(([name, channel]) =>
|
|
109
|
+
` ${propertyKey(name)}: ChannelContract<${names.get(channel.paramsSchema)}, ${names.get(channel.payloadSchema)}, ${channel.version}>;`
|
|
110
|
+
);
|
|
111
|
+
const versions = Object.entries(config.channels).map(([name, channel]) => ` ${propertyKey(name)}: ${channel.version}`);
|
|
112
|
+
const types = [
|
|
113
|
+
header,
|
|
114
|
+
`import type { ChannelContract } from "@streamotter/client";`,
|
|
115
|
+
"",
|
|
116
|
+
...schemaTypes.flatMap(block => [block, ""]),
|
|
117
|
+
`/** Channel contracts for createClient<AppChannels>(). Project "${config.projectId}". */`,
|
|
118
|
+
"export type AppChannels = {",
|
|
119
|
+
...channels,
|
|
120
|
+
"};",
|
|
121
|
+
"",
|
|
122
|
+
"/** Deployed channel versions; subscribe requests must name them exactly. */",
|
|
123
|
+
"export const channelVersions = {",
|
|
124
|
+
versions.join(",\n"),
|
|
125
|
+
"} as const;",
|
|
126
|
+
""
|
|
127
|
+
].join("\n");
|
|
128
|
+
|
|
129
|
+
const [firstName, firstChannel] = Object.entries(config.channels)[0] ?? [];
|
|
130
|
+
const paramsSchema = firstChannel === undefined ? undefined : config.schemas[firstChannel.paramsSchema];
|
|
131
|
+
const exampleParams = paramsSchema?.type === "object"
|
|
132
|
+
? `{ ${Object.entries(paramsSchema.properties).map(([key, schema]) =>
|
|
133
|
+
`${propertyKey(key)}: ${schema.type === "string" ? (schema.enum?.[0] !== undefined ? JSON.stringify(schema.enum[0]) : `"example"`) : schema.type === "boolean" ? "true" : "1"}`).join(", ")} }`
|
|
134
|
+
: "{}";
|
|
135
|
+
const example = firstName === undefined ? `${header}\nexport {};\n` : [
|
|
136
|
+
header,
|
|
137
|
+
`import { createClient, type StreamError, type SubscriptionState } from "@streamotter/client";`,
|
|
138
|
+
`import { channelVersions, type AppChannels } from "./streamotter.generated.js";`,
|
|
139
|
+
"",
|
|
140
|
+
"/**",
|
|
141
|
+
` * Subscribes to "${firstName}" and returns a cleanup function. Replace render/showState/showError`,
|
|
142
|
+
" * with your view code. getToken must return your application's session token for the gateway.",
|
|
143
|
+
" */",
|
|
144
|
+
`export async function mount${pascalCase(firstName)}(options: {`,
|
|
145
|
+
" origin: string;",
|
|
146
|
+
` params: AppChannels[${JSON.stringify(firstName)}]["params"];`,
|
|
147
|
+
" getToken: (signal: AbortSignal) => Promise<string>;",
|
|
148
|
+
` render: (data: AppChannels[${JSON.stringify(firstName)}]["data"]) => void;`,
|
|
149
|
+
" showState?: (state: SubscriptionState) => void;",
|
|
150
|
+
" showError?: (error: StreamError) => void;",
|
|
151
|
+
"}): Promise<() => Promise<void>> {",
|
|
152
|
+
" const client = createClient<AppChannels>({ origin: options.origin, getToken: ({ signal }) => options.getToken(signal) });",
|
|
153
|
+
` const subscription = client.subscribe(${JSON.stringify(firstName)}, {`,
|
|
154
|
+
` channelVersion: channelVersions[${JSON.stringify(firstName)}],`,
|
|
155
|
+
" params: options.params",
|
|
156
|
+
" });",
|
|
157
|
+
" subscription.on(\"data\", event => options.render(event.data));",
|
|
158
|
+
" subscription.on(\"state\", ({ state }) => options.showState?.(state));",
|
|
159
|
+
" subscription.on(\"error\", error => options.showError?.(error));",
|
|
160
|
+
" try {",
|
|
161
|
+
" await subscription.ready({ timeoutMs: 30_000 });",
|
|
162
|
+
" } catch (error) {",
|
|
163
|
+
" await subscription.unsubscribe();",
|
|
164
|
+
" await client.close();",
|
|
165
|
+
" throw error;",
|
|
166
|
+
" }",
|
|
167
|
+
" return async () => {",
|
|
168
|
+
" await subscription.unsubscribe();",
|
|
169
|
+
" await client.close();",
|
|
170
|
+
" };",
|
|
171
|
+
"}",
|
|
172
|
+
"",
|
|
173
|
+
`// Example: await mount${pascalCase(firstName)}({ origin: "http://localhost:${config.gateway.port}", params: ${exampleParams}, getToken, render });`,
|
|
174
|
+
""
|
|
175
|
+
].join("\n");
|
|
176
|
+
|
|
177
|
+
return [
|
|
178
|
+
{ path: "streamotter.generated.ts", content: types },
|
|
179
|
+
{ path: "streamotter.client.example.ts", content: example }
|
|
180
|
+
];
|
|
181
|
+
}
|
package/src/index.ts
ADDED
package/src/main.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { runCli } from "./cli.ts";
|
|
2
|
+
|
|
3
|
+
const shutdownSignal = new Promise<string>(resolve => {
|
|
4
|
+
process.once("SIGINT", () => resolve("SIGINT"));
|
|
5
|
+
process.once("SIGTERM", () => resolve("SIGTERM"));
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const code = await runCli(process.argv.slice(2), {
|
|
9
|
+
out: line => { process.stdout.write(`${line}\n`); },
|
|
10
|
+
err: line => { process.stderr.write(`${line}\n`); },
|
|
11
|
+
shutdownSignal
|
|
12
|
+
});
|
|
13
|
+
// Flush output, then exit even if a dependency left a handle open.
|
|
14
|
+
process.stdout.write("", () => process.stderr.write("", () => process.exit(code)));
|
package/src/templates.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Files created by `streamotter init`. The scaffold is fixture-only and has no production credentials. */
|
|
2
|
+
export function scaffoldFiles(projectId: string): { path: string; content: string }[] {
|
|
3
|
+
const config = {
|
|
4
|
+
configVersion: 1,
|
|
5
|
+
projectId,
|
|
6
|
+
gateway: { host: "127.0.0.1", port: 7400, path: "/streamotter/socket.io", allowedOrigins: ["http://localhost:5173"] },
|
|
7
|
+
connections: {},
|
|
8
|
+
sources: { jobs: { kind: "fixture", generation: "jobs-fixture-1", fixtureRef: "jobs" } },
|
|
9
|
+
schemas: {
|
|
10
|
+
JobParams: {
|
|
11
|
+
type: "object", additionalProperties: false, required: ["jobId"],
|
|
12
|
+
properties: { jobId: { type: "string", minLength: 1, maxLength: 64 } }
|
|
13
|
+
},
|
|
14
|
+
JobProgress: {
|
|
15
|
+
type: "object", additionalProperties: false, required: ["jobId", "state", "percent"],
|
|
16
|
+
properties: {
|
|
17
|
+
jobId: { type: "string", minLength: 1, maxLength: 64 },
|
|
18
|
+
state: { type: "string", enum: ["queued", "running", "succeeded", "failed"] },
|
|
19
|
+
percent: { type: "integer", minimum: 0, maximum: 100 }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
channels: {
|
|
24
|
+
jobProgress: {
|
|
25
|
+
version: 1, source: "jobs", paramsSchema: "JobParams", payloadSchema: "JobProgress",
|
|
26
|
+
handlersRef: "jobProgress", delivery: { kind: "state", overflow: "resync" }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handlers = `// @ts-check
|
|
32
|
+
/**
|
|
33
|
+
* Trusted server handlers for StreamOtter, loaded by:
|
|
34
|
+
* streamotter dev --config streamotter.json --handlers server/handlers.mjs
|
|
35
|
+
*
|
|
36
|
+
* StreamOtter loads compiled JavaScript. If you write handlers in TypeScript,
|
|
37
|
+
* compile them first and point --handlers at the output.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Development read model. Replace it with your application's authoritative store.
|
|
42
|
+
* Snapshots and mapped records must describe the same revision progression.
|
|
43
|
+
* @type {Map<string, { revision: string; data: { jobId: string; state: string; percent: number } }>}
|
|
44
|
+
*/
|
|
45
|
+
const jobs = new Map([
|
|
46
|
+
["local/job_1", { revision: "1", data: { jobId: "job_1", state: "queued", percent: 0 } }]
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** @type {import("@streamotter/gateway").HandlerRegistry<any>} */
|
|
50
|
+
export const handlers = {
|
|
51
|
+
async authenticate({ token }) {
|
|
52
|
+
// TODO: verify your application's session token and return its Principal
|
|
53
|
+
// ({ subject, tenantId, sessionId, expiresAt, claims }), or null to reject it.
|
|
54
|
+
// Workbench previews use short-lived preview tokens resolved by the gateway itself,
|
|
55
|
+
// so this scaffold intentionally accepts no application tokens yet.
|
|
56
|
+
void token;
|
|
57
|
+
return null;
|
|
58
|
+
},
|
|
59
|
+
channels: {
|
|
60
|
+
jobProgress: {
|
|
61
|
+
async authorize({ principal, params }) {
|
|
62
|
+
return jobs.has(\`\${principal.tenantId}/\${params.jobId}\`);
|
|
63
|
+
},
|
|
64
|
+
map({ record }) {
|
|
65
|
+
const value = /** @type {{ tenantId: string; revision: string; job: { jobId: string; state: string; percent: number } }} */ (record.value);
|
|
66
|
+
// Development only: keep the in-memory read model in step with the fixture stream.
|
|
67
|
+
const key = \`\${value.tenantId}/\${value.job.jobId}\`;
|
|
68
|
+
const current = jobs.get(key);
|
|
69
|
+
if (current === undefined || BigInt(value.revision) > BigInt(current.revision)) {
|
|
70
|
+
jobs.set(key, { revision: value.revision, data: value.job });
|
|
71
|
+
}
|
|
72
|
+
return [{ tenantId: value.tenantId, params: { jobId: value.job.jobId }, revision: value.revision, data: value.job }];
|
|
73
|
+
},
|
|
74
|
+
async snapshot({ principal, params }) {
|
|
75
|
+
const job = jobs.get(\`\${principal.tenantId}/\${params.jobId}\`);
|
|
76
|
+
if (job === undefined) throw new Error("Unknown job");
|
|
77
|
+
return { revision: job.revision, data: job.data };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Development-only principals and fixture records. Never used by \`streamotter start\`. */
|
|
84
|
+
export const development = {
|
|
85
|
+
principals: {
|
|
86
|
+
developer: { subject: "developer", tenantId: "local", sessionId: "dev-session", expiresAt: "2099-01-01T00:00:00.000Z", claims: {} }
|
|
87
|
+
},
|
|
88
|
+
fixtures: {
|
|
89
|
+
jobs: [
|
|
90
|
+
{ key: "job_1", value: { tenantId: "local", revision: "2", job: { jobId: "job_1", state: "running", percent: 25 } } },
|
|
91
|
+
{ key: "job_1", value: { tenantId: "local", revision: "3", job: { jobId: "job_1", state: "running", percent: 60 } } },
|
|
92
|
+
{ key: "job_1", value: { tenantId: "local", revision: "4", job: { jobId: "job_1", state: "running", percent: 90 } } },
|
|
93
|
+
{ key: "job_1", value: { tenantId: "local", revision: "5", job: { jobId: "job_1", state: "succeeded", percent: 100 } } }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const example = `import { createClient } from "@streamotter/client";
|
|
100
|
+
import { channelVersions, type AppChannels } from "../generated/streamotter.generated.js";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Renders one job's live progress. Run \`streamotter generate --config streamotter.json --out generated\`
|
|
104
|
+
* first. getToken must return your application's session token (or, during local development,
|
|
105
|
+
* a preview token from the workbench).
|
|
106
|
+
*/
|
|
107
|
+
export async function watchJob(jobId: string, getToken: () => Promise<string>, element: HTMLElement): Promise<() => Promise<void>> {
|
|
108
|
+
const client = createClient<AppChannels>({ origin: "http://localhost:7400", getToken: () => getToken() });
|
|
109
|
+
const job = client.subscribe("jobProgress", { channelVersion: channelVersions.jobProgress, params: { jobId } });
|
|
110
|
+
job.on("data", ({ data, revision }) => {
|
|
111
|
+
element.textContent = \`\${data.state} — \${data.percent}% (revision \${revision})\`;
|
|
112
|
+
});
|
|
113
|
+
job.on("state", ({ state }) => {
|
|
114
|
+
element.dataset["delivery"] = state; // "live" means synchronized; anything else may be stale.
|
|
115
|
+
});
|
|
116
|
+
job.on("error", error => console.warn(\`[\${error.code}] \${error.message}\`));
|
|
117
|
+
await job.ready();
|
|
118
|
+
return async () => {
|
|
119
|
+
await job.unsubscribe();
|
|
120
|
+
await client.close();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
const readme = `# ${projectId}
|
|
126
|
+
|
|
127
|
+
A StreamOtter V1 project created by \`streamotter init\`. It uses a deterministic fixture source,
|
|
128
|
+
so no Kafka broker is needed to start.
|
|
129
|
+
|
|
130
|
+
## Run locally
|
|
131
|
+
|
|
132
|
+
\`\`\`bash
|
|
133
|
+
streamotter validate --config streamotter.json
|
|
134
|
+
streamotter dev --config streamotter.json --handlers server/handlers.mjs
|
|
135
|
+
\`\`\`
|
|
136
|
+
|
|
137
|
+
\`dev\` prints the workbench URL and a one-time management token. In the workbench, create a
|
|
138
|
+
preview session for the \`developer\` principal, subscribe to \`jobProgress\` with \`{"jobId": "job_1"}\`,
|
|
139
|
+
and advance the \`jobs\` fixture to watch revisions arrive.
|
|
140
|
+
|
|
141
|
+
## Integrate
|
|
142
|
+
|
|
143
|
+
\`\`\`bash
|
|
144
|
+
streamotter generate --config streamotter.json --out generated
|
|
145
|
+
\`\`\`
|
|
146
|
+
|
|
147
|
+
\`generated/streamotter.generated.ts\` contains \`AppChannels\`; \`web/example.ts\` shows a subscription
|
|
148
|
+
with cleanup and error handling.
|
|
149
|
+
|
|
150
|
+
## Before production
|
|
151
|
+
|
|
152
|
+
- Implement \`authenticate\` in \`server/handlers.mjs\` with your real session verification.
|
|
153
|
+
- Replace the in-memory read model with your authoritative store.
|
|
154
|
+
- Replace the fixture source with a Kafka source using TLS; \`streamotter start\` rejects fixtures and plaintext Kafka.
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
return [
|
|
158
|
+
{ path: "streamotter.json", content: `${JSON.stringify(config, null, 2)}\n` },
|
|
159
|
+
{ path: "server/handlers.mjs", content: handlers },
|
|
160
|
+
{ path: "web/example.ts", content: example },
|
|
161
|
+
{ path: "README.md", content: readme }
|
|
162
|
+
];
|
|
163
|
+
}
|