diff --git a/.gitignore b/.gitignore index 17e6094..1c3d2c7 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ dist # Default cookie-store from `iracing-data` CLI packages/cli/bin/*.json +examples/oauth-example-cli/credentials.json # SEA build directories build/ diff --git a/README.md b/README.md index 8a906a7..ba2ef2d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A monorepo of TypeScript packages for working with the iRacing `/data` API and t ## Packages ### API + - [@iracing-data/api-schema](packages/api/schema/README.md) – Zod schemas for `/data` endpoints. - [@iracing-data/api-schema-to-openapi](packages/helpers/api-schema-to-openapi/README.md) – Generate OpenAPI docs from the schemas. - [@iracing-data/api-client-fetch](packages/api/client/fetch/README.md) – Fetch-based API client. @@ -12,11 +13,13 @@ A monorepo of TypeScript packages for working with the iRacing `/data` API and t - [@iracing-data/api-router](packages/api/router/README.md) – Better Call router bundling generated routes. ### OAuth + - [@iracing-data/oauth-schema](packages/oauth/schema/README.md) – OAuth request/response Zod schemas. - [@iracing-data/oauth-schema-to-openapi](packages/helpers/oauth-schema-to-openapi/README.md) – OpenAPI generation from OAuth schemas. - [@iracing-data/oauth-client](packages/oauth/client/README.md) – OAuth client implementation. ### Telemetry + - [@iracing-data/telemetry-types](packages/telemetry/types/README.md) – Generated telemetry TypeScript types. - [@iracing-data/telemetry-client-grpc-node](packages/telemetry/client/grpc-node/README.md) – Node gRPC telemetry client. - [@iracing-data/telemetry-client-grpc-web](packages/telemetry/client/grpc-web/README.md) – Browser gRPC-Web telemetry client. @@ -24,6 +27,7 @@ A monorepo of TypeScript packages for working with the iRacing `/data` API and t - [@iracing-data/telemetry-client-http](packages/telemetry/client/http/README.md) – Axios HTTP telemetry client. ### Telemetry Event Emitters + - [@iracing-data/session-state-events](packages/events/session-state-events/README.md) - [@iracing-data/track-location-events](packages/events/track-location-events/README.md) - [@iracing-data/car-track-location-events](packages/events/car-track-location-events/README.md) @@ -36,17 +40,24 @@ A monorepo of TypeScript packages for working with the iRacing `/data` API and t - [@iracing-data/driver-swap-events](packages/events/driver-swap-events/README.md) ### Helpers + - [@iracing-data/helpers/sync-car-assets](packages/helpers/sync-car-assets/README.md) - [@iracing-data/helpers/sync-track-assets](packages/helpers/sync-track-assets/README.md) - [@iracing-data/helpers/sync-telemetry-json-schema](packages/helpers/sync-telemetry-json-schema/README.md) - [@iracing-data/helpers/iracing-json-schema-to-typescript](packages/helpers/iracing-json-schema-to-typescript/README.md) ## Apps + - [race-events](apps/race-events/README.md) – Example CLI that logs race events from telemetry. - [@iracing-data/sync-car-assets-cli](apps/sync-car-assets-cli/README.md) – Download car assets via CLI. - [@iracing-data/sync-track-assets-cli](apps/sync-track-assets-cli/README.md) – Download track assets via CLI. +## Examples + +See [examples/README.md](./examples/README.md). + ## Rust + - [iracing-data-api-client](crates/iracing-data-api-client/README.md) – Generated Rust client for the iRacing `/data` API. Use `cargo get-member --access-token "$IRACING_ACCESS_TOKEN" --customer-ids 378767 --include-licenses` to run the member lookup example. ## Development diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..7355b59 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,10 @@ +# Examples + +Use this directory as a table of contents for all runnable examples. + +- [kapps-hello-world](./kapps-hello-world/README.md) - Connects to a Kapps telemetry WebSocket and logs sample telemetry keys. +- [oauth-example](./oauth-example/README.md) - Browser-based OAuth app with login/logout routes and cookie-backed session state. +- [oauth-example-cli](./oauth-example-cli/README.md) - CLI OAuth flow that opens browser auth and writes credentials to disk. +- [oauth-password-limited](./oauth-password-limited/README.md) - Password-limited OAuth example that fetches iRacing `/data` assets into local output files. +- [player-pit-stop-events](./player-pit-stop-events/README.md) - Streams telemetry and emits/logs player pit road, pit stall, and service lifecycle events. +- [telemetry-client](./telemetry-client/README.md) - Basic gRPC telemetry client that subscribes to updates and prints telemetry data. diff --git a/examples/oauth-example-cli/README.md b/examples/oauth-example-cli/README.md new file mode 100644 index 0000000..a39b677 --- /dev/null +++ b/examples/oauth-example-cli/README.md @@ -0,0 +1,31 @@ +# oauth-example-cli + +CLI OAuth example that starts the iRacing browser authorization flow, waits for +the callback on localhost, and writes returned credentials to +`examples/oauth-example-cli/credentials.json`. + +## Usage + +1. Create an `.env` file in this directory with: + +```bash +IRACING_AUTH_CLIENT= +IRACING_AUTH_SECRET= +OAUTH_CALLBACK_PORT=4040 +``` + +2. Run: + +```bash +pnpm --filter iracing-oauth-example-cli start +``` + +Optional flags: + +```bash +pnpm --filter iracing-oauth-example-cli start -- --callback-port 4040 --credentials-path ./credentials.json --timeout-seconds 300 +``` + +3. Press Enter in the CLI prompt to open your browser. +4. Complete sign-in in the browser and return to the CLI. +5. Find credentials in `credentials.json`. diff --git a/examples/oauth-example-cli/package.json b/examples/oauth-example-cli/package.json new file mode 100644 index 0000000..e8ab914 --- /dev/null +++ b/examples/oauth-example-cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "iracing-oauth-example-cli", + "version": "0.0.1", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc --build tsconfig.build.json", + "start": "tsx --tsconfig tsconfig.build.json --env-file=.env src/index.ts" + }, + "dependencies": { + "@iracing-data/oauth-client": "workspace:*", + "commander": "^14.0.2", + "dotenv": "17.2.3" + }, + "devDependencies": { + "@commander-js/extra-typings": "^14.0.0", + "@types/node": "^24.10.0", + "tsx": "^4.21.0" + } +} diff --git a/examples/oauth-example-cli/src/index.ts b/examples/oauth-example-cli/src/index.ts new file mode 100644 index 0000000..cb26112 --- /dev/null +++ b/examples/oauth-example-cli/src/index.ts @@ -0,0 +1,247 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { stdin, stdout } from "node:process"; +import { createInterface } from "node:readline/promises"; +import { fileURLToPath } from "node:url"; +import { Command } from "@commander-js/extra-typings"; +import { + InMemoryStore, + InternalState, + IRacingOAuthTokenResponse, + OAuthClient, +} from "@iracing-data/oauth-client"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const CALLBACK_PATH = "/oauth/iracing/callback"; +const CALLBACK_HOST = "127.0.0.1"; +const SCOPES: Array<"iracing.profile" | "iracing.auth"> = [ + "iracing.profile", + "iracing.auth", +]; +const SESSION_ID = "oauth-example-cli"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRootDir = path.join(__dirname, ".."); +const defaultCredentialsPath = path.join(projectRootDir, "credentials.json"); + +function getRequiredEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + + return value; +} + +function openUrlInBrowser(url: string): Promise { + return new Promise((resolve, reject) => { + const command = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = + process.platform === "win32" ? ["/c", "start", "", url] : [url]; + + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + }); + + child.on("error", reject); + child.unref(); + resolve(); + }); +} + +async function waitForOAuthCallback( + client: OAuthClient, + callbackPort: number, + credentialsPath: string, + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + let isSettled = false; + + const server = createServer(async (request, response) => { + try { + const requestUrl = new URL( + request.url ?? "", + `http://${CALLBACK_HOST}:${callbackPort}`, + ); + + if (requestUrl.pathname !== CALLBACK_PATH) { + response.statusCode = 404; + response.end("Not found."); + return; + } + + const token = await client.callback( + new URLSearchParams(requestUrl.searchParams), + SESSION_ID, + ); + + await writeFile( + credentialsPath, + `${JSON.stringify(token, null, 2)}\n`, + { + encoding: "utf-8", + }, + ); + + response.statusCode = 200; + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end( + "

Authenticated

You can now return to the CLI.

", + ); + + if (!isSettled) { + isSettled = true; + server.close(); + resolve(token); + } + } catch (error) { + response.statusCode = 500; + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.end( + "

OAuth callback failed

See CLI output for details.

", + ); + + if (!isSettled) { + isSettled = true; + server.close(); + reject(error); + } + } + }); + + server.on("error", (error) => { + if (!isSettled) { + isSettled = true; + reject(error); + } + }); + + server.listen(callbackPort, CALLBACK_HOST, () => { + const timeout = setTimeout(() => { + if (!isSettled) { + isSettled = true; + server.close(); + reject( + new Error( + `Timed out waiting for OAuth callback after ${timeoutMs / 1000}s.`, + ), + ); + } + }, timeoutMs); + + server.once("close", () => { + clearTimeout(timeout); + }); + }); + }); +} + +const program = new Command("oauth-example-cli") + .description( + "CLI OAuth example for iRacing. Opens browser auth and writes credentials to disk.", + ) + .option( + "--callback-port ", + "Local port for the OAuth callback server", + process.env.OAUTH_CALLBACK_PORT ?? "4040", + ) + .option( + "--credentials-path ", + "Output path for credentials.json", + defaultCredentialsPath, + ) + .option( + "--timeout-seconds ", + "How long to wait for callback before failing", + "300", + ) + .action(async (_, command) => { + const { callbackPort, credentialsPath, timeoutSeconds } = + command.optsWithGlobals(); + + const resolvedPort = Number(callbackPort); + if (!Number.isInteger(resolvedPort) || resolvedPort < 1) { + throw new Error(`Invalid callback port: "${callbackPort}"`); + } + + const resolvedTimeoutSeconds = Number(timeoutSeconds); + if ( + !Number.isFinite(resolvedTimeoutSeconds) || + resolvedTimeoutSeconds <= 0 + ) { + throw new Error(`Invalid timeout seconds: "${timeoutSeconds}"`); + } + + const timeoutMs = Math.round(resolvedTimeoutSeconds * 1000); + + const clientId = getRequiredEnv("IRACING_AUTH_CLIENT"); + const redirectUri = `http://${CALLBACK_HOST}:${resolvedPort}${CALLBACK_PATH}`; + const clientSecret = process.env.IRACING_AUTH_SECRET; + + const client = new OAuthClient({ + clientMetadata: { + clientId, + clientSecret, + redirectUri, + scopes: SCOPES, + }, + stateStore: new InMemoryStore(), + sessionStore: new InMemoryStore(), + }); + + const { url } = await client.authorize(); + + console.info("iRacing OAuth CLI Example"); + console.info(`Redirect URI: ${redirectUri}`); + console.info(`Credentials path: ${credentialsPath}`); + + const readline = createInterface({ input: stdin, output: stdout }); + await readline.question("Press Enter to open your browser and continue..."); + readline.close(); + + try { + await openUrlInBrowser(url.toString()); + console.info("Opened browser for OAuth sign-in."); + } catch (error) { + console.warn("Could not open browser automatically."); + console.warn("Open this URL manually:"); + console.warn(url.toString()); + console.warn(error); + } + + console.info("Waiting for OAuth callback..."); + const token = await waitForOAuthCallback( + client, + resolvedPort, + credentialsPath, + timeoutMs, + ); + + console.info("OAuth authentication complete."); + console.info(`Access token expires in: ${token.expires_in}s`); + if (token.refresh_token_expires_in) { + console.info( + `Refresh token expires in: ${token.refresh_token_expires_in}s`, + ); + } + }); + +program.parseAsync().catch((error) => { + console.error("OAuth CLI flow failed."); + console.error(error); + process.exitCode = 1; +}); diff --git a/examples/oauth-example-cli/tsconfig.build.json b/examples/oauth-example-cli/tsconfig.build.json new file mode 100644 index 0000000..f1295e5 --- /dev/null +++ b/examples/oauth-example-cli/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "module": "preserve", + "target": "es2022", + "moduleResolution": "bundler", + "noUnusedLocals": false, + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/examples/oauth-example-cli/tsconfig.json b/examples/oauth-example-cli/tsconfig.json new file mode 100644 index 0000000..9abc5e7 --- /dev/null +++ b/examples/oauth-example-cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }], + "compilerOptions": { + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/examples/oauth-example/README.md b/examples/oauth-example/README.md new file mode 100644 index 0000000..95c23e7 --- /dev/null +++ b/examples/oauth-example/README.md @@ -0,0 +1,23 @@ +# oauth-example + +Browser-based OAuth example app for iRacing. It runs a local web server with +login/logout routes, starts OAuth in the browser, handles the callback, and +sets an auth cookie for the session. + +## Usage + +1. Create an `.env` file in this directory with: + +```bash +IRACING_AUTH_CLIENT= +IRACING_AUTH_SECRET= +PORT=3000 +``` + +2. Run: + +```bash +pnpm --filter iracing-oauth-example dev +``` + +3. Open `http://127.0.0.1:3000` and use the Login link. diff --git a/examples/oauth-password-limited/README.md b/examples/oauth-password-limited/README.md new file mode 100644 index 0000000..a2253da --- /dev/null +++ b/examples/oauth-password-limited/README.md @@ -0,0 +1,36 @@ +# oauth-password-limited + +Password-limited OAuth example for iRacing that authenticates, restores cached +credentials when available, and fetches `/data` API assets into local files. + +## Usage + +1. Copy the environment template: + +```bash +cp .env.template .env +``` + +2. Update `.env` with your credentials: + +```bash +IRACING_AUTH_CLIENT= +IRACING_AUTH_SECRET= +IRACING_AUTH_USERNAME= +IRACING_AUTH_PASSWORD= +``` + +3. Run the example from the repository root: + +```bash +pnpm --filter iracing-password-limited-oauth start +``` + +## Output + +- OAuth credentials are persisted to + `examples/oauth-password-limited/output/credentials.json`. +- `/data` API responses are written into + `examples/oauth-password-limited/output`. +- Track SVG layers are written into + `examples/oauth-password-limited/output/tracks`. diff --git a/examples/oauth-password-limited/package.json b/examples/oauth-password-limited/package.json index 704826b..b8268dd 100644 --- a/examples/oauth-password-limited/package.json +++ b/examples/oauth-password-limited/package.json @@ -9,9 +9,11 @@ "dependencies": { "@iracing-data/api-client-fetch": "workspace:*", "@iracing-data/oauth-client": "workspace:*", + "commander": "^14.0.2", "zod": "^4.3.6" }, "devDependencies": { + "@commander-js/extra-typings": "^14.0.0", "tsx": "^4.21.0" } -} \ No newline at end of file +} diff --git a/examples/oauth-password-limited/src/index.ts b/examples/oauth-password-limited/src/index.ts index ec8735f..e6f4886 100644 --- a/examples/oauth-password-limited/src/index.ts +++ b/examples/oauth-password-limited/src/index.ts @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import { access, constants, @@ -7,6 +9,7 @@ import { } from "node:fs/promises"; import path, { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { Command } from "@commander-js/extra-typings"; import { CarApi, Configuration, @@ -272,7 +275,7 @@ async function fetchData(configuration: Configuration, accessToken?: string) { } } -async function main() { +async function runPasswordLimitedOAuthExample() { const stateStore = new InMemoryStore(); const sessionStore = new DiskStore( credentialsPath, @@ -321,4 +324,16 @@ async function main() { console.log("Fetched assets from `/data` API."); } -main(); +const program = new Command("oauth-password-limited") + .description( + "Authenticate with iRacing using password_limited grant and fetch /data assets.", + ) + .action(async () => { + await runPasswordLimitedOAuthExample(); + }); + +program.parseAsync().catch((error) => { + console.error("oauth-password-limited failed."); + console.error(error); + process.exitCode = 1; +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a937309..85c7461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,28 @@ importers: specifier: ^4.19.0 version: 4.21.0 + examples/oauth-example-cli: + dependencies: + '@iracing-data/oauth-client': + specifier: workspace:* + version: link:../../packages/oauth/client + commander: + specifier: ^14.0.2 + version: 14.0.2 + dotenv: + specifier: 17.2.3 + version: 17.2.3 + devDependencies: + '@commander-js/extra-typings': + specifier: ^14.0.0 + version: 14.0.0(commander@14.0.2) + '@types/node': + specifier: ^24.10.0 + version: 24.10.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + examples/oauth-password-limited: dependencies: '@iracing-data/api-client-fetch': @@ -222,10 +244,16 @@ importers: '@iracing-data/oauth-client': specifier: workspace:* version: link:../../packages/oauth/client + commander: + specifier: ^14.0.2 + version: 14.0.2 zod: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@commander-js/extra-typings': + specifier: ^14.0.0 + version: 14.0.0(commander@14.0.2) tsx: specifier: ^4.21.0 version: 4.21.0