Skip to content

Commit bd944af

Browse files
committed
feat: step 3 - run the router
1 parent da5df27 commit bd944af

File tree

4 files changed

+247
-9
lines changed

4 files changed

+247
-9
lines changed

cli/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ COSMO_API_KEY=cosmo_669b576aaadc10ee1ae81d9193425705
22
COSMO_API_URL=http://localhost:3001
33
CDN_URL=http://localhost:11000
44
PLUGIN_REGISTRY_URL=
5+
DEFAULT_TELEMETRY_ENDPOINT=http://localhost:4318
6+
GRAPHQL_METRICS_COLLECTOR_ENDPOINT=http://localhost:4005
57

68
# configure running wgc behind a proxy
79
# HTTPS_PROXY=""

cli/src/commands/demo/command.ts

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
printLogo,
2323
publishAllPlugins,
2424
resetScreen,
25+
runRouterContainer,
2526
updateScreenWithUserInfo,
2627
} from './util.js';
2728

@@ -169,16 +170,18 @@ async function handleStep2(
169170
userInfo,
170171
supportDir,
171172
signal,
173+
logPath,
172174
}: {
173175
onboarding: { finishedAt?: string };
174176
userInfo: UserInfo;
175177
supportDir: string;
176178
signal: AbortSignal;
179+
logPath: string;
177180
},
178181
) {
179182
function retryFn() {
180183
resetScreen(userInfo);
181-
return handleStep2(opts, { onboarding, userInfo, supportDir, signal });
184+
return handleStep2(opts, { onboarding, userInfo, supportDir, signal, logPath });
182185
}
183186

184187
const graphData = await handleGetFederatedGraphResponse(opts.client, {
@@ -202,15 +205,17 @@ async function handleStep2(
202205
},
203206
'Hit [ENTER] to continue or [d] to delete the federated graph and its subgraphs to start over. CTRL+C to quit.',
204207
);
205-
return;
208+
return { routingUrl: graph.routingURL };
206209
}
207210

208211
await handleCreateFederatedGraphResponse(opts.client, {
209212
onboarding,
210213
userInfo,
211214
});
212215

213-
const logPath = getDemoLogPath();
216+
const routingUrl = new URL('graphql', 'http://localhost');
217+
routingUrl.port = String(config.demoRouterPort);
218+
214219
console.log(`\nPublishing plugins… ${pc.dim(`(logs: ${logPath})`)}`);
215220

216221
const publishResult = await publishAllPlugins({
@@ -229,12 +234,27 @@ async function handleStep2(
229234
'Hit [r] to retry. CTRL+C to quit.',
230235
);
231236
}
237+
238+
return { routingUrl: routingUrl.toString() };
232239
}
233240

234-
async function handleStep3(opts: BaseCommandOptions, { userInfo }: { userInfo: UserInfo }) {
241+
async function handleStep3(
242+
opts: BaseCommandOptions,
243+
{
244+
userInfo,
245+
routerBaseUrl,
246+
signal,
247+
logPath,
248+
}: {
249+
userInfo: UserInfo;
250+
routerBaseUrl: string;
251+
signal: AbortSignal;
252+
logPath: string;
253+
},
254+
) {
235255
function retryFn() {
236256
resetScreen(userInfo);
237-
return handleStep3(opts, { userInfo });
257+
return handleStep3(opts, { userInfo, routerBaseUrl, signal, logPath });
238258
}
239259

240260
const tokenParams = {
@@ -262,9 +282,51 @@ async function handleStep3(opts: BaseCommandOptions, { userInfo }: { userInfo: U
262282
}
263283

264284
spinner.succeed('Router token generated.');
265-
console.log(`\n${pc.bold(createResult.token)}\n`);
266285

267-
// TODO: Step 3b — run router Docker container
286+
const sampleQuery = JSON.stringify({
287+
query: `query GetProductWithReviews($id: ID!) { product(id: $id) { id title price { currency amount } reviews { id author rating contents } } }`,
288+
variables: { id: 'product-1' },
289+
});
290+
291+
async function fireSampleQuery() {
292+
const querySpinner = ora('Sending sample query…').start();
293+
try {
294+
const res = await fetch(`${routerBaseUrl}/graphql`, {
295+
method: 'POST',
296+
headers: {
297+
'Content-Type': 'application/json',
298+
'GraphQL-Client-Name': 'wgc',
299+
},
300+
body: sampleQuery,
301+
});
302+
const body = await res.json();
303+
querySpinner.succeed('Sample query response:');
304+
console.log(pc.dim(JSON.stringify(body, null, 2)));
305+
} catch (err) {
306+
querySpinner.fail(`Sample query failed: ${err instanceof Error ? err.message : String(err)}`);
307+
}
308+
showQueryPrompt();
309+
}
310+
311+
function showQueryPrompt() {
312+
waitForKeyPress(
313+
{ r: fireSampleQuery, R: fireSampleQuery },
314+
'Hit [r] to send a sample query. CTRL+C to stop the router.',
315+
);
316+
}
317+
318+
const routerResult = await runRouterContainer({
319+
routerToken: createResult.token!,
320+
routerBaseUrl,
321+
signal,
322+
logPath,
323+
onReady: showQueryPrompt,
324+
});
325+
326+
if (routerResult.error) {
327+
console.error(`\nRouter exited with error: ${routerResult.error.message}`);
328+
await waitForKeyPress({ r: retryFn, R: retryFn }, 'Hit [r] to retry. CTRL+C to quit.');
329+
}
268330
}
269331

270332
async function handleGetOnboardingResponse(client: BaseCommandOptions['client'], userInfo: UserInfo) {
@@ -346,9 +408,22 @@ export default function (opts: BaseCommandOptions) {
346408
return;
347409
}
348410

349-
await handleStep2(opts, { onboarding: onboardingCheck, userInfo, supportDir, signal: controller.signal });
411+
const logPath = getDemoLogPath();
412+
413+
const step2Result = await handleStep2(opts, {
414+
onboarding: onboardingCheck,
415+
userInfo,
416+
supportDir,
417+
signal: controller.signal,
418+
logPath,
419+
});
420+
421+
if (!step2Result) {
422+
return;
423+
}
350424

351-
await handleStep3(opts, { userInfo });
425+
const routerBaseUrl = new URL(step2Result.routingUrl).origin;
426+
await handleStep3(opts, { userInfo, routerBaseUrl, signal: controller.signal, logPath });
352427
} finally {
353428
process.off('SIGINT', cleanup);
354429
process.off('SIGTERM', cleanup);

cli/src/commands/demo/util.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,163 @@ function pipeToLog(logStream: WriteStream, proc: ResultPromise) {
266266
proc.stderr?.pipe(logStream, { end: false });
267267
}
268268

269+
/**
270+
* Rewrite localhost to host.docker.internal so the container can
271+
* reach services running on the host machine.
272+
*/
273+
function toDockerHost(url: string) {
274+
return url.replace(/localhost/g, 'host.docker.internal');
275+
}
276+
277+
/**
278+
* Best-effort removal of a potentially stale router container
279+
* from a previous crashed run.
280+
*/
281+
async function removeRouterContainer(): Promise<void> {
282+
try {
283+
await execa('docker', ['rm', '-f', config.demoRouterContainerName]);
284+
} catch {
285+
// ignore — container may not exist
286+
}
287+
}
288+
289+
/**
290+
* Polls the router's readiness endpoint until it responds 200
291+
* or the signal is aborted / max attempts exceeded.
292+
*/
293+
async function waitForRouterReady({
294+
routerBaseUrl,
295+
signal,
296+
intervalMs = 1000,
297+
maxAttempts = 60,
298+
}: {
299+
routerBaseUrl: string;
300+
signal: AbortSignal;
301+
intervalMs?: number;
302+
maxAttempts?: number;
303+
}): Promise<boolean> {
304+
const url = `${routerBaseUrl}/health/ready`;
305+
306+
for (let i = 0; i < maxAttempts; i++) {
307+
if (signal.aborted) {
308+
return false;
309+
}
310+
try {
311+
const res = await fetch(url, { signal });
312+
if (res.ok) {
313+
return true;
314+
}
315+
} catch {
316+
// not up yet
317+
}
318+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
319+
}
320+
321+
return false;
322+
}
323+
324+
/**
325+
* Runs the cosmo router as a Docker container. Shows an ora spinner
326+
* that transitions from "Starting…" to "Router is ready" once the
327+
* health endpoint responds. The process stays alive until the abort
328+
* signal fires (CTRL+C / crash) or docker exits on its own.
329+
*/
330+
export async function runRouterContainer({
331+
routerToken,
332+
routerBaseUrl,
333+
signal,
334+
logPath,
335+
onReady,
336+
}: {
337+
routerToken: string;
338+
routerBaseUrl: string;
339+
signal: AbortSignal;
340+
logPath: string;
341+
onReady?: () => void;
342+
}): Promise<{ error: Error | null }> {
343+
await removeRouterContainer();
344+
345+
const port = config.demoRouterPort;
346+
347+
const args = [
348+
'run',
349+
'--name',
350+
config.demoRouterContainerName,
351+
'--rm',
352+
'-p',
353+
`${port}:${port}`,
354+
'--add-host=host.docker.internal:host-gateway',
355+
'--pull',
356+
'always',
357+
'-e',
358+
'DEV_MODE=true',
359+
'-e',
360+
'LOG_LEVEL=debug',
361+
'-e',
362+
`LISTEN_ADDR=0.0.0.0:${port}`,
363+
'-e',
364+
`GRAPH_API_TOKEN=${routerToken}`,
365+
'-e',
366+
'PLUGINS_ENABLED=true',
367+
];
368+
369+
// Local-dev env vars — only forwarded when set in the wgc process.
370+
371+
const conditionalEnvs: Array<[string, string | undefined]> = [
372+
['CDN_URL', process.env.CDN_URL],
373+
['REGISTRY_URL', process.env.PLUGIN_REGISTRY_URL],
374+
['PLUGINS_REGISTRY_URL', process.env.PLUGIN_REGISTRY_URL],
375+
['CONTROLPLANE_URL', process.env.COSMO_API_URL],
376+
['DEFAULT_TELEMETRY_ENDPOINT', process.env.DEFAULT_TELEMETRY_ENDPOINT],
377+
['GRAPHQL_METRICS_COLLECTOR_ENDPOINT', process.env.GRAPHQL_METRICS_COLLECTOR_ENDPOINT],
378+
];
379+
380+
for (const [key, value] of conditionalEnvs) {
381+
if (value) {
382+
args.push('-e', `${key}=${toDockerHost(value)}`);
383+
}
384+
}
385+
386+
args.push(config.demoRouterImage);
387+
388+
const logStream = createWriteStream(logPath, { flags: 'a' });
389+
const spinner = ora(`Starting router on ${pc.bold(routerBaseUrl)}…`).start();
390+
391+
try {
392+
const proc = execa('docker', args, {
393+
stdio: 'pipe',
394+
...(signal ? { cancelSignal: signal } : {}),
395+
});
396+
397+
pipeToLog(logStream, proc);
398+
399+
// Poll readiness in parallel with the long-running docker process
400+
waitForRouterReady({ routerBaseUrl, signal }).then((ready) => {
401+
if (ready) {
402+
spinner.succeed(`Router is ready on ${pc.bold(routerBaseUrl)}.`);
403+
console.log(pc.dim(`(logs: ${logPath})`));
404+
onReady?.();
405+
} else if (!signal.aborted) {
406+
spinner.warn('Router started but readiness check timed out. It may still be starting.');
407+
console.log(pc.dim(`(logs: ${logPath})`));
408+
}
409+
});
410+
411+
await proc;
412+
} catch (error) {
413+
// Graceful abort — not an error
414+
if (error instanceof Error && 'isCanceled' in error && (error as any).isCanceled) {
415+
return { error: null };
416+
}
417+
spinner.fail('Router failed to start.');
418+
return { error: error instanceof Error ? error : new Error(String(error)) };
419+
} finally {
420+
logStream.end();
421+
}
422+
423+
return { error: null };
424+
}
425+
269426
/**
270427
* Publishes demo plugins sequentially.
271428
* Returns [error] on first failure; spinner shows which plugin failed.

cli/src/core/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,13 @@ export const config = {
4242
demoOnboardingRepositoryName: 'wundergraph/cosmo-onboarding' as const,
4343
demoOnboardingRepositoryBranch: 'main' as const,
4444
dockerBuilderName: 'cosmo-builder' as const,
45+
defaultTelemetryEndpoint: process.env.DEFAULT_TELEMETRY_ENDPOINT,
46+
graphqlMetricsCollectorEndpoint: process.env.GRAPHQL_METRICS_COLLECTOR_ENDPOINT,
4547
demoRouterPort: 3002 as const,
4648
demoPluginNames: ['products', 'reviews'] as const,
4749
demoRouterTokenName: 'demo-router-token' as const,
50+
demoRouterImage: 'ghcr.io/wundergraph/cosmo/router:latest' as const,
51+
demoRouterContainerName: 'cosmo-router' as const,
4852
};
4953

5054
export const getBaseHeaders = (): HeadersInit => {

0 commit comments

Comments
 (0)