@valerypopoff/rivet2-cli 2.0.10 → 2.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.
@@ -1,8 +1,11 @@
1
- import { createProcessor, loadProjectFromFile } from '@valerypopoff/rivet2-node';
1
+ import { createProcessor } from '@valerypopoff/rivet2-node';
2
+ import { configDotenv } from 'dotenv';
2
3
  import { resolve } from 'node:path';
3
- import { parseJsonInputRecord, parseKeyValueInputRecord } from '../commandInputs.js';
4
+ import { parseJsonInputRecord, parseJsonInputRecordFromFile, parseJsonKeyValueInputRecord, parseKeyValueInputRecord, throwIfConflictingInputSources, } from '../commandInputs.js';
5
+ import { addDatasetOptions, addProviderOptions, loadProjectRuntime, throwIfInvalidGraph, throwIfNoMainGraph, withCliProcessorOptions, } from '../cliRuntime.js';
6
+ import { shapeOutputs, validateOutputShapeOptions, writeJsonOutput } from '../output.js';
4
7
  export function makeCommand(y) {
5
- return y
8
+ const command = addProviderOptions(y)
6
9
  .positional('projectFile', {
7
10
  describe: 'The project file to run',
8
11
  type: 'string',
@@ -16,17 +19,49 @@ export function makeCommand(y) {
16
19
  describe: 'Read inputs from stdin as JSON',
17
20
  type: 'boolean',
18
21
  default: false,
22
+ })
23
+ .option('inputs-file', {
24
+ describe: 'Read inputs from a JSON file',
25
+ type: 'string',
26
+ })
27
+ .option('input-json', {
28
+ describe: 'Adds a JSON input to the graph run using key=json',
29
+ type: 'string',
30
+ array: true,
31
+ default: [],
19
32
  })
20
33
  .option('include-cost', {
21
34
  describe: 'Include the total cost in the output',
22
35
  type: 'boolean',
23
36
  default: false,
37
+ })
38
+ .option('output-key', {
39
+ describe: 'Print only one named output Data Value wrapper',
40
+ type: 'string',
41
+ })
42
+ .option('unwrap-output', {
43
+ describe: 'Print only the .value field from one named output',
44
+ type: 'string',
45
+ })
46
+ .option('output-file', {
47
+ describe: 'Write the JSON output to a file instead of stdout',
48
+ type: 'string',
24
49
  })
25
50
  .option('context', {
26
51
  describe: 'Adds a context value to the graph run',
27
52
  type: 'string',
28
53
  array: true,
29
54
  default: [],
55
+ })
56
+ .option('context-json', {
57
+ describe: 'Adds a JSON context value using key=json',
58
+ type: 'string',
59
+ array: true,
60
+ default: [],
61
+ })
62
+ .option('context-file', {
63
+ describe: 'Read context values from a JSON file',
64
+ type: 'string',
30
65
  })
31
66
  .option('input', {
32
67
  describe: 'Adds an input to the graph run',
@@ -34,44 +69,60 @@ export function makeCommand(y) {
34
69
  array: true,
35
70
  default: [],
36
71
  });
72
+ return addDatasetOptions(command);
37
73
  }
38
74
  export async function run(args) {
39
- try {
40
- const projectPath = resolve(process.cwd(), args.projectFile);
41
- const project = await loadProjectFromFile(projectPath);
42
- if (!args.graphName && !project.metadata.mainGraphId) {
43
- const validGraphs = Object.values(project.graphs).map((graph) => [graph.metadata.id, graph.metadata.name]);
44
- const validGraphNames = validGraphs.map(([id, name]) => `- "${name}" (${id})`);
45
- console.error(`No graph name provided, and project does not specify a main graph. Valid graphs are: \n${validGraphNames.join('\n')}\n\nUse either the graph's name or its ID. For example, \`rivet run my-project.rivet-project my-graph\` or \`rivet run my-project.rivet-project 1234abcd\``);
46
- process.exit(1);
47
- }
48
- let inputs;
49
- if (args.inputsStdin) {
50
- const stdin = process.stdin;
51
- stdin.setEncoding('utf8');
52
- let inputText = '';
53
- for await (const chunk of stdin) {
54
- inputText += chunk;
55
- }
56
- inputs = parseJsonInputRecord(inputText, 'Input stdin');
57
- }
58
- else {
59
- inputs = parseKeyValueInputRecord(args.input, 'input');
60
- }
61
- const contextValues = parseKeyValueInputRecord(args.context, 'context');
62
- const { run } = createProcessor(project, {
63
- graph: args.graphName,
64
- inputs,
65
- context: contextValues,
66
- });
67
- const outputs = await run();
68
- if (!args.includeCost) {
69
- delete outputs.cost;
70
- }
71
- console.log(JSON.stringify(outputs, null, 2));
75
+ configDotenv();
76
+ validateOutputShapeOptions(args);
77
+ const runtime = await loadProjectRuntime(args.projectFile, args);
78
+ throwIfNoMainGraph(runtime.project, args.graphName, runtime.projectPath, 'positional');
79
+ throwIfInvalidGraph(runtime.project, args.graphName, 'positional');
80
+ const { run: runProcessor } = createProcessor(runtime.project, withCliProcessorOptions(runtime, args, {
81
+ context: await readContext(args),
82
+ graph: args.graphName,
83
+ inputs: await readInputs(args),
84
+ }));
85
+ await writeJsonOutput(shapeOutputs(await runProcessor(), {
86
+ includeCost: args.includeCost,
87
+ outputKey: args.outputKey,
88
+ unwrapOutput: args.unwrapOutput,
89
+ }), args.outputFile);
90
+ }
91
+ async function readInputs(args) {
92
+ throwIfConflictingInputSources([
93
+ { enabled: args.inputsStdin, name: '--inputs-stdin' },
94
+ { enabled: args.inputsFile != null, name: '--inputs-file' },
95
+ { enabled: args.input.length > 0 || args.inputJson.length > 0, name: '--input/--input-json' },
96
+ ], 'input');
97
+ if (args.inputsStdin) {
98
+ return parseJsonInputRecord(await readStdin(), 'Input stdin');
99
+ }
100
+ if (args.inputsFile) {
101
+ return parseJsonInputRecordFromFile(resolve(process.cwd(), args.inputsFile), 'Input file');
72
102
  }
73
- catch (err) {
74
- console.error(err);
75
- process.exit(1);
103
+ return {
104
+ ...parseKeyValueInputRecord(args.input, 'input'),
105
+ ...parseJsonKeyValueInputRecord(args.inputJson, 'input-json'),
106
+ };
107
+ }
108
+ async function readContext(args) {
109
+ throwIfConflictingInputSources([
110
+ { enabled: args.contextFile != null, name: '--context-file' },
111
+ { enabled: args.context.length > 0 || args.contextJson.length > 0, name: '--context/--context-json' },
112
+ ], 'context');
113
+ if (args.contextFile) {
114
+ return parseJsonInputRecordFromFile(resolve(process.cwd(), args.contextFile), 'Context file');
115
+ }
116
+ return {
117
+ ...parseKeyValueInputRecord(args.context, 'context'),
118
+ ...parseJsonKeyValueInputRecord(args.contextJson, 'context-json'),
119
+ };
120
+ }
121
+ async function readStdin() {
122
+ process.stdin.setEncoding('utf8');
123
+ let inputText = '';
124
+ for await (const chunk of process.stdin) {
125
+ inputText += chunk;
76
126
  }
127
+ return inputText;
77
128
  }
@@ -1,18 +1,22 @@
1
- import { serve as serveHono } from '@hono/node-server';
2
1
  import { createProcessor, getSingleNodeStream, loadProjectFromFile } from '@valerypopoff/rivet2-node';
3
2
  import chalk from 'chalk';
4
- import didYouMean from 'didyoumean2';
5
3
  import { configDotenv } from 'dotenv';
6
4
  import { Hono } from 'hono';
7
- import { readdir, stat } from 'node:fs/promises';
8
- import { basename, dirname, extname, join, resolve } from 'node:path';
9
5
  import { parseJsonInputRecord } from '../commandInputs.js';
6
+ import { addDatasetOptions, addProviderOptions, loadProjectRuntime, resolveServedGraphName, throwIfInvalidGraph, throwIfNoMainGraph, warnIfServerSavesDatasets, withCliProcessorOptions, withProviderProcessorOptions, } from '../cliRuntime.js';
7
+ import { CliHttpError, addHttpOptions, createHttpMiddleware, formatListenUrl, jsonErrorResponse, jsonTimedResponse, startHttpServer, } from '../http.js';
8
+ import { shapeOutputs } from '../output.js';
10
9
  export function makeCommand(y) {
11
- return y
10
+ const command = addHttpOptions(addProviderOptions(y)
12
11
  .option('port', {
13
12
  describe: 'The port to serve on',
14
13
  type: 'number',
15
14
  default: 3000,
15
+ })
16
+ .option('host', {
17
+ describe: 'The host interface to bind to',
18
+ type: 'string',
19
+ default: '0.0.0.0',
16
20
  })
17
21
  .option('dev', {
18
22
  describe: 'Run in development mode: rereads the project file on each request',
@@ -29,25 +33,21 @@ export function makeCommand(y) {
29
33
  type: 'boolean',
30
34
  default: false,
31
35
  })
32
- .option('openai-api-key', {
33
- describe: 'The OpenAI API key to use for the project. If omitted, the environment variable OPENAI_API_KEY is used.',
34
- type: 'string',
35
- demandOption: false,
36
- })
37
- .option('openai-endpoint', {
38
- describe: 'The OpenAI API endpoint to use for the project. If omitted, the environment variable OPENAI_ENDPOINT is used.',
36
+ .option('endpoint', {
37
+ describe: 'Expose a named endpoint alias using endpointName=graphNameOrId',
39
38
  type: 'string',
40
- demandOption: false,
41
- })
42
- .option('openai-organization', {
43
- describe: 'The OpenAI organization to use for the project. If omitted, the environment variable OPENAI_ORGANIZATION is used.',
44
- type: 'string',
45
- demandOption: false,
46
- })
39
+ array: true,
40
+ default: [],
41
+ }));
42
+ return addDatasetOptions(command)
47
43
  .option('expose-cost', {
48
44
  describe: 'Expose the cost of the graph run in the response',
49
45
  type: 'boolean',
50
46
  default: false,
47
+ })
48
+ .option('unwrap-output', {
49
+ describe: 'Respond with only the .value field from one named output',
50
+ type: 'string',
51
51
  })
52
52
  .option('stream', {
53
53
  describe: 'Turns on streaming mode. Rivet events will be sent to the client using SSE (Server-Sent Events). If this is set to a Node ID or node title, only events for that node will be sent.',
@@ -66,94 +66,96 @@ export function makeCommand(y) {
66
66
  });
67
67
  }
68
68
  export async function serve(args) {
69
- try {
70
- configDotenv();
71
- const app = new Hono();
72
- const projectFilePath = await getProjectFile(args.projectFile);
73
- const initialProject = await loadProjectFromFile(projectFilePath);
74
- throwIfNoMainGraph(initialProject, args.graph, projectFilePath);
75
- throwIfInvalidGraph(initialProject, args.graph);
76
- if (args.stream != null) {
77
- console.log('Streaming is enabled');
69
+ configDotenv();
70
+ const { app, projectFilePath, servedGraphName } = await createServeApp(args);
71
+ startHttpServer(app, args.host, args.port);
72
+ console.log(chalk.green(`Serving project file ${chalk.bold.white(projectFilePath)} at ${chalk.bold.white(formatListenUrl(args.host, args.port))}.\nServing graph "${chalk.bold.white(servedGraphName)}".`));
73
+ }
74
+ export async function createServeApp(args) {
75
+ const runtime = await loadProjectRuntime(args.projectFile, args);
76
+ const endpointAliases = parseEndpointAliases(args.endpoint, runtime.project);
77
+ throwIfNoMainGraph(runtime.project, args.graph, runtime.projectPath);
78
+ throwIfInvalidGraph(runtime.project, args.graph);
79
+ if (args.stream != null) {
80
+ console.log('Streaming is enabled');
81
+ }
82
+ warnIfServerSavesDatasets(args.saveDatasets, 'CLI server');
83
+ if (args.streamNode != null) {
84
+ if (args.stream == null) {
85
+ throw new Error('--stream-node requires --stream.');
78
86
  }
79
- if (args.streamNode != null) {
80
- if (args.stream == null) {
81
- throw new Error('--stream-node requires --stream.');
82
- }
83
- console.log(`Streaming node ${chalk.bold(args.streamNode)}`);
87
+ console.log(`Streaming node ${chalk.bold(args.streamNode)}`);
88
+ }
89
+ const app = new Hono();
90
+ app.use('*', createHttpMiddleware(args));
91
+ app.get('/healthz', (c) => c.json({
92
+ ok: true,
93
+ projectPath: runtime.projectPath,
94
+ }));
95
+ app.post('/', async (c) => handleGraphRequest(c, args, runtime, args.graph));
96
+ app.post('/endpoints/:endpointName', async (c) => {
97
+ const endpointName = c.req.param('endpointName');
98
+ const graph = endpointAliases.get(endpointName);
99
+ if (!graph) {
100
+ return jsonErrorResponse(c, new CliHttpError(`Endpoint "${endpointName}" not found.`, 404), Date.now(), 404);
84
101
  }
85
- app.post('/', async (c) => {
86
- const project = args.dev ? await loadProjectFromFile(projectFilePath) : initialProject;
87
- const inputs = parseJsonInputRecord(await c.req.text(), 'Request body');
88
- const graphRunArgs = buildGraphRunArgs(args, project, inputs, args.graph);
89
- if (args.stream != null) {
90
- const stream = await streamGraph({
91
- ...graphRunArgs,
92
- stream: args.stream,
93
- streamNode: args.streamNode,
94
- });
95
- return new Response(stream, {
96
- headers: {
97
- 'Content-Type': 'text/event-stream',
98
- 'Cache-Control': 'no-cache',
99
- Connection: 'keep-alive',
100
- },
101
- });
102
- }
103
- return c.json(await runGraph(graphRunArgs));
104
- });
105
- if (args.allowSpecifyingGraphId) {
106
- app.post('/:graphId', async (c) => {
107
- const project = args.dev ? await loadProjectFromFile(projectFilePath) : initialProject;
108
- const graph = c.req.param('graphId');
109
- throwIfInvalidGraph(project, graph);
110
- const inputs = parseJsonInputRecord(await c.req.text(), 'Request body');
111
- return c.json(await runGraph(buildGraphRunArgs(args, project, inputs, graph)));
102
+ return handleGraphRequest(c, args, runtime, graph);
103
+ });
104
+ if (args.allowSpecifyingGraphId) {
105
+ app.post('/:graphId', async (c) => handleGraphRequest(c, args, runtime, c.req.param('graphId')));
106
+ }
107
+ return {
108
+ app,
109
+ projectFilePath: runtime.projectPath,
110
+ servedGraphName: resolveServedGraphName(runtime.project, args.graph),
111
+ };
112
+ }
113
+ async function handleGraphRequest(c, args, runtime, graph) {
114
+ const startedAt = Date.now();
115
+ let graphRunArgs;
116
+ try {
117
+ const project = args.dev ? await loadProjectFromFile(runtime.projectPath) : runtime.project;
118
+ throwIfInvalidGraph(project, graph);
119
+ const inputs = parseJsonInputRecord(await c.req.text(), 'Request body');
120
+ graphRunArgs = buildGraphRunArgs(args, runtime, project, inputs, graph);
121
+ }
122
+ catch (error) {
123
+ return jsonErrorResponse(c, toBadRequestError(error), startedAt, 400);
124
+ }
125
+ try {
126
+ if (args.stream != null) {
127
+ const stream = await streamGraph({
128
+ ...graphRunArgs,
129
+ stream: args.stream,
130
+ streamNode: args.streamNode,
112
131
  });
113
- }
114
- const server = serveHono({
115
- port: args.port,
116
- fetch: app.fetch,
117
- });
118
- const servedGraphName = resolveServedGraphName(initialProject, args.graph);
119
- console.log(chalk.green(`Serving project file ${chalk.bold.white(projectFilePath)} on port ${chalk.bold.white(args.port)}.\nServing graph "${chalk.bold.white(servedGraphName)}".`));
120
- function shutdown() {
121
- console.log('Shutting down...');
122
- server.close((err) => {
123
- if (err) {
124
- console.error(err);
125
- process.exit(1);
126
- }
127
- process.exit(0);
132
+ return new Response(stream, {
133
+ headers: {
134
+ 'Cache-Control': 'no-cache',
135
+ Connection: 'keep-alive',
136
+ 'Content-Type': 'text/event-stream',
137
+ },
128
138
  });
129
139
  }
130
- process.on('SIGINT', shutdown);
131
- process.on('SIGTERM', shutdown);
140
+ return jsonTimedResponse(c, await runGraph(graphRunArgs), startedAt);
132
141
  }
133
- catch (err) {
134
- console.error(chalk.red(err));
135
- process.exit(1);
142
+ catch (error) {
143
+ return jsonErrorResponse(c, error, startedAt, 500);
136
144
  }
137
145
  }
138
- function buildGraphRunArgs(args, project, inputs, graph) {
146
+ function buildGraphRunArgs(args, runtime, project, inputs, graph) {
139
147
  return {
140
148
  exposeCost: args.exposeCost,
141
149
  graph,
142
150
  inputs,
143
- openaiApiKey: args.openaiApiKey,
144
- openaiEndpoint: args.openaiEndpoint,
145
- openaiOrganization: args.openaiOrganization,
146
151
  project,
152
+ providerOptions: args,
153
+ runtime,
154
+ unwrapOutput: args.unwrapOutput,
147
155
  };
148
156
  }
149
- async function streamGraph({ project, inputs, graph, openaiApiKey, openaiEndpoint, openaiOrganization, stream, streamNode, }) {
150
- const { run, processor, getSSEStream } = createProcessor(project, {
151
- inputs,
152
- graph,
153
- openAiKey: openaiApiKey,
154
- openAiEndpoint: openaiEndpoint,
155
- openAiOrganization: openaiOrganization,
156
- });
157
+ async function streamGraph({ project, inputs, graph, providerOptions, runtime, stream, streamNode, }) {
158
+ const { run, processor, getSSEStream } = createProcessor(project, withCliProcessorOptions(runtime, providerOptions, buildStreamingGraphProcessorOptions({ graph, inputs })));
157
159
  const responseStream = streamNode
158
160
  ? getSingleNodeStream(processor, streamNode)
159
161
  : getSSEStream(buildStreamEventFilter(stream));
@@ -162,127 +164,66 @@ async function streamGraph({ project, inputs, graph, openaiApiKey, openaiEndpoin
162
164
  });
163
165
  return responseStream;
164
166
  }
167
+ export function buildGraphProcessorOptions({ inputs, graph, openaiApiKey, openaiEndpoint, openaiOrganization, }) {
168
+ return withProviderProcessorOptions({ openaiApiKey, openaiEndpoint, openaiOrganization }, {
169
+ graph,
170
+ inputs,
171
+ });
172
+ }
173
+ export function buildStreamingGraphProcessorOptions(args) {
174
+ return {
175
+ ...buildGraphProcessorOptions(args),
176
+ runtimeProfile: 'compatible',
177
+ };
178
+ }
165
179
  export function buildStreamEventFilter(stream) {
166
180
  const streamTarget = stream?.trim();
167
181
  if (!streamTarget) {
168
182
  return {
169
- nodeStart: true,
170
183
  nodeFinish: true,
184
+ nodeStart: true,
171
185
  partialOutputs: true,
172
186
  };
173
187
  }
174
188
  return {
175
- nodeStart: [streamTarget],
176
189
  nodeFinish: [streamTarget],
190
+ nodeStart: [streamTarget],
177
191
  partialOutputs: [streamTarget],
178
192
  };
179
193
  }
180
- async function runGraph({ project, inputs, graph, openaiApiKey, openaiEndpoint, openaiOrganization, exposeCost, }) {
181
- const { run } = createProcessor(project, {
182
- inputs,
183
- graph,
184
- openAiKey: openaiApiKey,
185
- openAiEndpoint: openaiEndpoint,
186
- openAiOrganization: openaiOrganization,
194
+ async function runGraph({ project, inputs, graph, providerOptions, exposeCost, runtime, unwrapOutput, }) {
195
+ const { run } = createProcessor(project, withCliProcessorOptions(runtime, providerOptions, { graph, inputs }));
196
+ return shapeOutputs(await run(), {
197
+ includeCost: exposeCost,
198
+ unwrapOutput,
187
199
  });
188
- const outputs = await run();
189
- if (!exposeCost) {
190
- delete outputs.cost;
191
- }
192
- return outputs;
193
- }
194
- function getGraphSummaries(project) {
195
- return Object.values(project.graphs).map((graph) => ({
196
- id: graph.metadata.id,
197
- name: graph.metadata.name,
198
- }));
199
- }
200
- function findGraph(project, graphIdOrName) {
201
- if (!graphIdOrName) {
202
- return undefined;
203
- }
204
- return getGraphSummaries(project).find((graph) => graph.id === graphIdOrName || graph.name === graphIdOrName);
205
- }
206
- function formatGraphList(project) {
207
- return getGraphSummaries(project)
208
- .map((graph) => `- "${graph.name}" (${graph.id})`)
209
- .join('\n');
210
- }
211
- function resolveServedGraphName(project, graph) {
212
- const servedGraph = findGraph(project, graph ?? project.metadata.mainGraphId);
213
- if (!servedGraph) {
214
- throw new Error(`Project main graph "${project.metadata.mainGraphId}" was not found in the project file.`);
215
- }
216
- return servedGraph.name;
217
200
  }
218
- function throwIfNoMainGraph(project, graph, projectFilePath) {
219
- if (project.metadata.mainGraphId || graph) {
220
- return;
221
- }
222
- const validGraphs = getGraphSummaries(project);
223
- if (validGraphs.length === 0) {
224
- throw new Error('No graphs found in the project file. Please edit the project file in Rivet and add a graph.');
225
- }
226
- const firstExample = `rivet serve ${projectFilePath} --graph ${validGraphs[0].id}`;
227
- const secondExample = `rivet serve ${projectFilePath} --graph "${validGraphs[0].name}"`;
228
- throw new Error(`No graph name provided, and project does not specify a main graph. Valid graphs are: \n\n${formatGraphList(project)}\n\nUse either the graph's name or its ID. For example, \n- \`${chalk.bold(firstExample)}\` or\n- \`${chalk.bold(secondExample)}\``);
229
- }
230
- function throwIfInvalidGraph(project, graph) {
231
- if (!graph) {
232
- const mainGraphId = project.metadata.mainGraphId;
233
- if (!mainGraphId || findGraph(project, mainGraphId)) {
234
- return;
201
+ export function parseEndpointAliases(endpointSpecs, project) {
202
+ const aliases = new Map();
203
+ for (const spec of endpointSpecs) {
204
+ const separatorIndex = spec.indexOf('=');
205
+ if (separatorIndex <= 0) {
206
+ throw new Error(`Invalid endpoint "${spec}". Expected endpointName=graphNameOrId.`);
235
207
  }
236
- throw new Error(`Project main graph "${mainGraphId}" was not found in the project file.`);
237
- }
238
- if (findGraph(project, graph)) {
239
- return;
240
- }
241
- const validGraphsAndIds = getGraphSummaries(project).flatMap((graph) => [graph.id, graph.name]);
242
- const suggestion = didYouMean(graph, validGraphsAndIds);
243
- if (suggestion) {
244
- throw new Error(`Graph "${graph}" not found in project file. Did you mean \`${chalk.bold(`--graph "${suggestion}"`)}\`?`);
245
- }
246
- throw new Error(`Graph "${graph}" not found in project file. Valid graphs are: \n${formatGraphList(project)}`);
247
- }
248
- async function getProjectFile(initialProjectFilePath) {
249
- let projectFilePath = resolve(process.cwd(), initialProjectFilePath ?? (await getProjectFilePathFromDirectory(process.cwd())));
250
- await throwIfMissingFile(projectFilePath);
251
- if ((await stat(projectFilePath)).isDirectory()) {
252
- projectFilePath = await getProjectFilePathFromDirectory(projectFilePath);
253
- }
254
- return projectFilePath;
255
- }
256
- async function getProjectFilePathFromDirectory(directory) {
257
- const files = await readdir(directory);
258
- const projectFiles = files.filter((file) => extname(file).toLowerCase() === '.rivet-project');
259
- if (projectFiles.length === 0) {
260
- throw new Error('No project file found in the current directory. Project files should end with .rivet-project.');
261
- }
262
- if (projectFiles.length > 1) {
263
- throw new Error(`Multiple project files found in the current directory. Please specify which one to serve: \n${projectFiles.join('\n')}`);
264
- }
265
- return join(directory, projectFiles[0]);
266
- }
267
- async function throwIfMissingFile(filePath) {
268
- try {
269
- await stat(filePath);
270
- }
271
- catch (err) {
272
- if (err.code !== 'ENOENT') {
273
- throw err;
274
- }
275
- let possibleFiles = [];
276
- try {
277
- possibleFiles = await readdir(dirname(filePath));
208
+ const endpointName = spec.slice(0, separatorIndex).trim();
209
+ const graph = spec.slice(separatorIndex + 1).trim();
210
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(endpointName)) {
211
+ throw new Error(`Invalid endpoint name "${endpointName}". Endpoint names must use letters, numbers, hyphens, or underscores, and must start with a letter or number.`);
278
212
  }
279
- catch {
280
- throw new Error(`Could not find project file "${filePath}".`);
213
+ if (!graph) {
214
+ throw new Error(`Invalid endpoint "${spec}". Expected a graph name or ID after "=".`);
281
215
  }
282
- const suggestion = didYouMean(basename(filePath), possibleFiles);
283
- if (suggestion) {
284
- throw new Error(`Could not find project file "${filePath}". Did you mean "${join(dirname(filePath), suggestion)}"?`);
216
+ if (aliases.has(endpointName)) {
217
+ throw new Error(`Duplicate endpoint "${endpointName}".`);
285
218
  }
286
- throw new Error(`Could not find project file "${filePath}".`);
219
+ throwIfInvalidGraph(project, graph);
220
+ aliases.set(endpointName, graph);
221
+ }
222
+ return aliases;
223
+ }
224
+ function toBadRequestError(error) {
225
+ if (error instanceof CliHttpError) {
226
+ return error;
287
227
  }
228
+ return new CliHttpError(error instanceof Error ? error.message : String(error), 400);
288
229
  }
@@ -0,0 +1,99 @@
1
+ import { createRivetWebAppHandler } from '@valerypopoff/rivet2-node';
2
+ import chalk from 'chalk';
3
+ import { configDotenv } from 'dotenv';
4
+ import { Hono } from 'hono';
5
+ import { addDatasetOptions, addProviderOptions, loadProjectRuntime, resolveUiGraph, warnIfServerSavesDatasets, withCliProcessorOptions, } from '../cliRuntime.js';
6
+ import { addHttpOptions, createHttpMiddleware, formatListenUrl, jsonErrorResponse, startHttpServer, } from '../http.js';
7
+ export function makeCommand(y) {
8
+ const command = addHttpOptions(addProviderOptions(y)
9
+ .positional('projectFile', {
10
+ describe: 'The project file containing the Rivet web app',
11
+ type: 'string',
12
+ demandOption: true,
13
+ })
14
+ .positional('uiGraph', {
15
+ describe: 'The web app name or ID to serve. If omitted, the only web app in the project is used.',
16
+ type: 'string',
17
+ })
18
+ .option('port', {
19
+ describe: 'The port to serve on',
20
+ type: 'number',
21
+ default: 3000,
22
+ })
23
+ .option('host', {
24
+ describe: 'The host interface to bind to',
25
+ type: 'string',
26
+ default: '0.0.0.0',
27
+ })
28
+ .option('base-path', {
29
+ describe: 'The URL base path where the web app is mounted',
30
+ type: 'string',
31
+ default: '/',
32
+ })
33
+ .option('dev', {
34
+ describe: 'Run in development mode: rereads the project file on each request',
35
+ type: 'boolean',
36
+ default: false,
37
+ })
38
+ .option('revision-key', {
39
+ describe: 'Opaque revision key embedded in HTML and checked on action requests',
40
+ type: 'string',
41
+ }));
42
+ return addDatasetOptions(command);
43
+ }
44
+ export async function serveApp(args) {
45
+ configDotenv();
46
+ const { app, projectFilePath, uiGraphName } = await createWebAppServeApp(args);
47
+ startHttpServer(app, args.host, args.port);
48
+ console.log(chalk.green(`Serving Rivet web app "${chalk.bold.white(uiGraphName)}" from ${chalk.bold.white(projectFilePath)} at ${chalk.bold.white(formatListenUrl(args.host, args.port))}.`));
49
+ }
50
+ export async function createWebAppServeApp(args) {
51
+ throwIfReservedBasePath(args.basePath);
52
+ const runtime = await loadProjectRuntime(args.projectFile, args);
53
+ const uiGraph = resolveUiGraph(runtime.project, args.uiGraph);
54
+ const staticHandler = args.dev ? undefined : createHandler(runtime, args, uiGraph.id);
55
+ warnIfWebAppBearerTokenIsConfigured(args);
56
+ warnIfServerSavesDatasets(args.saveDatasets, 'CLI web-app server');
57
+ const app = new Hono();
58
+ app.use('*', createHttpMiddleware(args));
59
+ app.get('/healthz', (c) => c.json({
60
+ ok: true,
61
+ projectPath: runtime.projectPath,
62
+ uiGraphId: uiGraph.id,
63
+ }));
64
+ app.all('*', async (c) => {
65
+ const startedAt = Date.now();
66
+ try {
67
+ const handler = staticHandler ?? createHandler(await loadProjectRuntime(args.projectFile, args), args, uiGraph.id);
68
+ return handler.handleRequest(c.req.raw);
69
+ }
70
+ catch (error) {
71
+ return jsonErrorResponse(c, error, startedAt, 500);
72
+ }
73
+ });
74
+ return {
75
+ app,
76
+ projectFilePath: runtime.projectPath,
77
+ uiGraphName: uiGraph.name,
78
+ };
79
+ }
80
+ function createHandler(runtime, args, uiGraphId = resolveUiGraph(runtime.project, args.uiGraph).id) {
81
+ return createRivetWebAppHandler(runtime.project, {
82
+ basePath: args.basePath,
83
+ createProcessorOptions: () => withCliProcessorOptions(runtime, args, {}),
84
+ revisionKey: args.revisionKey,
85
+ uiGraphId,
86
+ });
87
+ }
88
+ function throwIfReservedBasePath(basePath) {
89
+ const normalizedBasePath = `/${basePath}`.replace(/\/+/g, '/').replace(/\/$/, '');
90
+ if (normalizedBasePath === '/healthz') {
91
+ throw new Error('--base-path /healthz is reserved for the CLI health endpoint.');
92
+ }
93
+ }
94
+ function warnIfWebAppBearerTokenIsConfigured(args) {
95
+ if (!args.bearerToken && !process.env.RIVET_CLI_BEARER_TOKEN) {
96
+ return;
97
+ }
98
+ console.warn(chalk.yellow('serve-app bearer-token auth expects clients to send Authorization headers. Browser navigation to the app HTML cannot add that header by itself; use a reverse proxy or cookie auth for browser-facing deployments.'));
99
+ }