From 179fda88d47bb0e5b0b3a69435f8d7af63dbf297 Mon Sep 17 00:00:00 2001 From: Devyash Saini Date: Tue, 27 Jan 2026 23:24:50 +0530 Subject: [PATCH] refactor: prettier AND tag expression serializer removes quotes Signed-off-by: Devyash Saini --- .github/workflows/commitlint.yml | 14 +- .prettierrc | 17 + README.md | 81 +- examples/ai-token-stream-usage.ts | 36 +- examples/middleware-usage.ts | 235 ++-- examples/sdkcall-usage.ts | 54 +- examples/tsconfig.json | 2 +- package.json | 3 +- packages/scrawn/README.md | 81 +- packages/scrawn/proto | 2 +- packages/scrawn/src/config.ts | 78 +- packages/scrawn/src/core/auth/apiKeyAuth.ts | 206 ++-- packages/scrawn/src/core/auth/baseAuth.ts | 192 ++-- packages/scrawn/src/core/errors/index.ts | 728 ++++++------- packages/scrawn/src/core/grpc/callContext.ts | 18 +- packages/scrawn/src/core/grpc/client.ts | 36 +- packages/scrawn/src/core/grpc/index.ts | 10 +- .../scrawn/src/core/grpc/requestBuilder.ts | 22 +- .../src/core/grpc/streamRequestBuilder.ts | 19 +- packages/scrawn/src/core/grpc/types.ts | 256 ++--- packages/scrawn/src/core/pricing/builders.ts | 66 +- packages/scrawn/src/core/pricing/index.ts | 22 +- packages/scrawn/src/core/pricing/serialize.ts | 72 +- packages/scrawn/src/core/pricing/types.ts | 14 +- packages/scrawn/src/core/pricing/validate.ts | 40 +- packages/scrawn/src/core/scrawn.ts | 999 ++++++++++-------- packages/scrawn/src/core/types/auth.ts | 68 +- packages/scrawn/src/core/types/event.ts | 380 +++---- .../scrawn/src/gen/auth/v1/auth_connect.ts | 3 +- packages/scrawn/src/gen/auth/v1/auth_pb.ts | 50 +- .../scrawn/src/gen/event/v1/event_connect.ts | 10 +- packages/scrawn/src/gen/event/v1/event_pb.ts | 414 +++++--- .../src/gen/payment/v1/payment_connect.ts | 8 +- .../scrawn/src/gen/payment/v1/payment_pb.ts | 69 +- packages/scrawn/src/index.ts | 48 +- .../scrawn/src/utils/forkAsyncIterable.ts | 24 +- packages/scrawn/src/utils/logger.ts | 152 +-- packages/scrawn/src/utils/pathMatcher.ts | 94 +- packages/scrawn/tests/mocks/mockTransport.ts | 9 +- .../scrawn/tests/unit/auth/apiKeyAuth.test.ts | 6 +- .../scrawn/tests/unit/pricing/pricing.test.ts | 57 +- .../tests/unit/scrawn/middleware.test.ts | 15 +- .../scrawn/tests/unit/scrawn/scrawn.test.ts | 43 +- .../unit/types/aiTokenUsagePayload.test.ts | 4 +- .../scrawn/tests/unit/utils/logger.test.ts | 8 +- tsconfig.json | 46 +- 46 files changed, 2661 insertions(+), 2150 deletions(-) create mode 100644 .prettierrc diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index f87ba1c..a91b30f 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,7 +1,7 @@ -name: commitlint - -on: pull_request - -jobs: - commitlint: - uses: ScrawnDotDev/.github/.github/workflows/commitlint.yml@master +name: commitlint + +on: pull_request + +jobs: + commitlint: + uses: ScrawnDotDev/.github/.github/workflows/commitlint.yml@master diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b15c93c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,17 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto" +} diff --git a/README.md b/README.md index 67ea132..428e420 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,40 @@ -# Scrawn SDK - -## What is Scrawn.js? - -Scrawn.js is the official TypeScript SDK for integrating Scrawn's usage-based billing into your applications. It provides a simple, type-safe interface for tracking usage events and collecting payments via gRPC. - -[View Docs](https://scrawn.vercel.app/docs/scrawn-js) - -## Key Features - -- **Simple API** - Track usage with a single function call -- **Type-Safe** - Full TypeScript support with auto-completion -- **gRPC-Powered** - Built on Connect-RPC for efficient communication -- **Framework Agnostic** - Works with any JavaScript framework -- **Middleware Support** - Built-in middleware with whitelist/blacklist patterns - -## Installation - -Install Scrawn.js in your project: - - -```bash -bun add @scrawn/core -``` - -## Quick Example - -```typescript -import { Scrawn } from '@scrawn/core'; - -const scrawn = new Scrawn({ - apiKey: process.env.SCRAWN_KEY as `scrn_${string}`, - baseURL: process.env.SCRAWN_BASE_URL || 'http://localhost:8069', -}); - -// Track a billable event -await scrawn.sdkCallEventConsumer({ - userId: 'user-123', - debitAmount: 100, -}); -``` \ No newline at end of file +# Scrawn SDK + +## What is Scrawn.js? + +Scrawn.js is the official TypeScript SDK for integrating Scrawn's usage-based billing into your applications. It provides a simple, type-safe interface for tracking usage events and collecting payments via gRPC. + +[View Docs](https://scrawn.vercel.app/docs/scrawn-js) + +## Key Features + +- **Simple API** - Track usage with a single function call +- **Type-Safe** - Full TypeScript support with auto-completion +- **gRPC-Powered** - Built on Connect-RPC for efficient communication +- **Framework Agnostic** - Works with any JavaScript framework +- **Middleware Support** - Built-in middleware with whitelist/blacklist patterns + +## Installation + +Install Scrawn.js in your project: + +```bash +bun add @scrawn/core +``` + +## Quick Example + +```typescript +import { Scrawn } from "@scrawn/core"; + +const scrawn = new Scrawn({ + apiKey: process.env.SCRAWN_KEY as `scrn_${string}`, + baseURL: process.env.SCRAWN_BASE_URL || "http://localhost:8069", +}); + +// Track a billable event +await scrawn.sdkCallEventConsumer({ + userId: "user-123", + debitAmount: 100, +}); +``` diff --git a/examples/ai-token-stream-usage.ts b/examples/ai-token-stream-usage.ts index ea8c2f5..6caa6a7 100644 --- a/examples/ai-token-stream-usage.ts +++ b/examples/ai-token-stream-usage.ts @@ -1,21 +1,21 @@ -import { Scrawn, type AITokenUsagePayload } from '@scrawn/core'; -import { config } from 'dotenv'; -config({ path: '.env.local' }); +import { Scrawn, type AITokenUsagePayload } from "@scrawn/core"; +import { config } from "dotenv"; +config({ path: ".env.local" }); const scrawn = new Scrawn({ - apiKey: (process.env.SCRAWN_KEY || '') as `scrn_${string}`, - baseURL: process.env.SCRAWN_BASE_URL || 'http://localhost:8069', + apiKey: (process.env.SCRAWN_KEY || "") as `scrn_${string}`, + baseURL: process.env.SCRAWN_BASE_URL || "http://localhost:8069", }); // Simulate what your AI provider wrapper would do: // As tokens stream from OpenAI/Anthropic/etc, you yield usage events async function* tokenUsageFromAIStream(): AsyncGenerator { - const userId = 'c0971bcb-b901-4c3e-a191-c9a97871c39f'; - + const userId = "c0971bcb-b901-4c3e-a191-c9a97871c39f"; + // Initial prompt tokens yield { userId, - model: 'gpt-4', + model: "gpt-4", inputTokens: 150, outputTokens: 0, inputDebit: { amount: 0.0045 }, @@ -25,7 +25,7 @@ async function* tokenUsageFromAIStream(): AsyncGenerator { // Output tokens as they stream yield { userId, - model: 'gpt-4', + model: "gpt-4", inputTokens: 0, outputTokens: 75, inputDebit: { amount: 0 }, @@ -36,28 +36,30 @@ async function* tokenUsageFromAIStream(): AsyncGenerator { // Example 1: Fire-and-forget mode (default) // The stream is consumed and sent to backend, you just await the final response async function fireAndForgetExample() { - console.log('--- Fire-and-forget mode ---'); - + console.log("--- Fire-and-forget mode ---"); + const response = await scrawn.aiTokenStreamConsumer(tokenUsageFromAIStream()); - + console.log(`Streamed ${response.eventsProcessed} token usage events`); } // Example 2: Return mode -// The stream is forked - one fork goes to billing (non-blocking), +// The stream is forked - one fork goes to billing (non-blocking), // the other is returned to you for streaming to the user async function returnModeExample() { - console.log('\n--- Return mode (with stream passthrough) ---'); - + console.log("\n--- Return mode (with stream passthrough) ---"); + const { response, stream } = await scrawn.aiTokenStreamConsumer( tokenUsageFromAIStream(), { return: true } ); // Stream tokens to user while billing happens in background - console.log('Streaming tokens to user:'); + console.log("Streaming tokens to user:"); for await (const token of stream) { - console.log(` -> ${token.model}: input=${token.inputTokens}, output=${token.outputTokens}`); + console.log( + ` -> ${token.model}: input=${token.inputTokens}, output=${token.outputTokens}` + ); } // Billing completes after stream is consumed diff --git a/examples/middleware-usage.ts b/examples/middleware-usage.ts index fce4c24..798b190 100644 --- a/examples/middleware-usage.ts +++ b/examples/middleware-usage.ts @@ -1,115 +1,120 @@ -import express from "express"; -import { EventPayload, Scrawn } from "@scrawn/core"; -import { config } from 'dotenv'; -config({path: '.env.local'}); - -const scrawn = new Scrawn({ - apiKey: (process.env.SCRAWN_KEY || '') as `scrn_${string}`, - baseURL: process.env.SCRAWN_BASE_URL || 'http://localhost:8069', -}); - -// Create Express app -const app = express(); -app.use(express.json()); - -app.use( - scrawn.middlewareEventConsumer({ - extractor: (req): EventPayload => { - // You gotta change this to fit your app's systumm - return { - userId: (req.headers?.["x-user-id"] as string) || "anonymous", - debitAmount: req.body?.cost || 1, - }; - }, - blacklist: ['/api/collect-payment', '/api/status'], - }) -); - -// API Routes -app.post("/api/generate", (req, res) => { - const { prompt } = req.body; - - const result = `Generated content for: ${prompt}`; - - res.json({ - success: true, - result, - billed: req.body?.cost || 1, - }); -}); - -app.post("/api/analyze", (req, res) => { - const { data } = req.body; - - const analysis = { - itemCount: data?.length || 0, - summary: "Analysis complete", - }; - - res.json({ - success: true, - analysis, - billed: req.body?.cost || 1, - }); -}); - -app.post("/api/translate", (req, res) => { - const { text, targetLang } = req.body; - - const translated = `[${targetLang}] ${text}`; - - res.json({ - success: true, - translated, - billed: req.body?.cost || 1, - }); -}); - -app.get("/api/status", (req, res) => { - res.json({ - status: "ok", - message: "Server is running", - }); -}); - -app.post("/api/collect-payment", async (req, res) => { - try { - const { userId } = req.body; - - if (!userId) { - return res.status(400).json({ - success: false, - error: "userId is required", - }); - } - - // Get checkout link from Scrawn - const checkoutLink = await scrawn.collectPayment(userId); - - // Redirect user to payment page - res.redirect(checkoutLink); - } catch (error) { - console.error('Failed to collect payment:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : "Failed to create checkout link", - }); - } -}); - -// Start server -const PORT = process.env.PORT || 3000; -app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); - console.log(`📊 Scrawn tracking enabled on all endpoints (except /api/collect-payment)`); - console.log(`\nTry it out:`); - console.log(`\nTrack an event:`); - console.log(`curl -X POST http://localhost:${PORT}/api/generate \\`); - console.log(` -H "Content-Type: application/json" \\`); - console.log(` -H "x-user-id: user_123" \\`); - console.log(` -d '{"prompt": "Hello world", "cost": 5}'`); - console.log(`\nCollect payment (redirects to checkout):`); - console.log(`curl -X POST http://localhost:${PORT}/api/collect-payment \\`); - console.log(` -H "Content-Type: application/json" \\`); - console.log(` -d '{"userId": "user_123"}'`); -}); +import express from "express"; +import { EventPayload, Scrawn } from "@scrawn/core"; +import { config } from "dotenv"; +config({ path: ".env.local" }); + +const scrawn = new Scrawn({ + apiKey: (process.env.SCRAWN_KEY || "") as `scrn_${string}`, + baseURL: process.env.SCRAWN_BASE_URL || "http://localhost:8069", +}); + +// Create Express app +const app = express(); +app.use(express.json()); + +app.use( + scrawn.middlewareEventConsumer({ + extractor: (req): EventPayload => { + // You gotta change this to fit your app's systumm + return { + userId: (req.headers?.["x-user-id"] as string) || "anonymous", + debitAmount: req.body?.cost || 1, + }; + }, + blacklist: ["/api/collect-payment", "/api/status"], + }) +); + +// API Routes +app.post("/api/generate", (req, res) => { + const { prompt } = req.body; + + const result = `Generated content for: ${prompt}`; + + res.json({ + success: true, + result, + billed: req.body?.cost || 1, + }); +}); + +app.post("/api/analyze", (req, res) => { + const { data } = req.body; + + const analysis = { + itemCount: data?.length || 0, + summary: "Analysis complete", + }; + + res.json({ + success: true, + analysis, + billed: req.body?.cost || 1, + }); +}); + +app.post("/api/translate", (req, res) => { + const { text, targetLang } = req.body; + + const translated = `[${targetLang}] ${text}`; + + res.json({ + success: true, + translated, + billed: req.body?.cost || 1, + }); +}); + +app.get("/api/status", (req, res) => { + res.json({ + status: "ok", + message: "Server is running", + }); +}); + +app.post("/api/collect-payment", async (req, res) => { + try { + const { userId } = req.body; + + if (!userId) { + return res.status(400).json({ + success: false, + error: "userId is required", + }); + } + + // Get checkout link from Scrawn + const checkoutLink = await scrawn.collectPayment(userId); + + // Redirect user to payment page + res.redirect(checkoutLink); + } catch (error) { + console.error("Failed to collect payment:", error); + res.status(500).json({ + success: false, + error: + error instanceof Error + ? error.message + : "Failed to create checkout link", + }); + } +}); + +// Start server +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`🚀 Server running on http://localhost:${PORT}`); + console.log( + `📊 Scrawn tracking enabled on all endpoints (except /api/collect-payment)` + ); + console.log(`\nTry it out:`); + console.log(`\nTrack an event:`); + console.log(`curl -X POST http://localhost:${PORT}/api/generate \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -H "x-user-id: user_123" \\`); + console.log(` -d '{"prompt": "Hello world", "cost": 5}'`); + console.log(`\nCollect payment (redirects to checkout):`); + console.log(`curl -X POST http://localhost:${PORT}/api/collect-payment \\`); + console.log(` -H "Content-Type: application/json" \\`); + console.log(` -d '{"userId": "user_123"}'`); +}); diff --git a/examples/sdkcall-usage.ts b/examples/sdkcall-usage.ts index e4915be..eceb7a9 100644 --- a/examples/sdkcall-usage.ts +++ b/examples/sdkcall-usage.ts @@ -1,27 +1,27 @@ -import { Scrawn } from '@scrawn/core'; -import { config } from 'dotenv'; -config({path: '.env.local'}); - -async function main() { - const scrawn = new Scrawn({ - apiKey: (process.env.SCRAWN_KEY || '') as `scrn_${string}`, - baseURL: process.env.SCRAWN_BASE_URL || 'http://localhost:8069', - }); - - await scrawn.sdkCallEventConsumer({ - userId: 'c0971bcb-b901-4c3e-a191-c9a97871c39f', - debitAmount: 3000, - }); - - await scrawn.sdkCallEventConsumer({ - userId: 'c0971bcb-b901-4c3e-a191-c9a97871c39f', - debitTag: 'PREMIUM_FEATURE', - }); - - console.log('SDK call events consumed successfully'); - - // const checkoutLink = await scrawn.collectPayment('c0971bcb-b901-4c3e-a191-c9a97871c39f'); - // console.log('Checkout link created:', checkoutLink, "\nGo to the link to complete the payment."); -} - -main().catch(console.error); \ No newline at end of file +import { Scrawn } from "@scrawn/core"; +import { config } from "dotenv"; +config({ path: ".env.local" }); + +async function main() { + const scrawn = new Scrawn({ + apiKey: (process.env.SCRAWN_KEY || "") as `scrn_${string}`, + baseURL: process.env.SCRAWN_BASE_URL || "http://localhost:8069", + }); + + await scrawn.sdkCallEventConsumer({ + userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", + debitAmount: 3000, + }); + + await scrawn.sdkCallEventConsumer({ + userId: "c0971bcb-b901-4c3e-a191-c9a97871c39f", + debitTag: "PREMIUM_FEATURE", + }); + + console.log("SDK call events consumed successfully"); + + // const checkoutLink = await scrawn.collectPayment('c0971bcb-b901-4c3e-a191-c9a97871c39f'); + // console.log('Checkout link created:', checkoutLink, "\nGo to the link to complete the payment."); +} + +main().catch(console.error); diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 24ab955..dd620fe 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["./*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist"] } diff --git a/package.json b/package.json index 8a3f0f1..6c1979f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "./clean_build.sh", "rebuild": "cd packages/scrawn && bun run build", "clean": "cd packages/scrawn && bun run clean", - "install:all": "bun install && cd packages/scrawn && bun install && cd ../.. && cd examples && bun install" + "format": "bunx prettier --write .", + "install:all": "bun install && cd packages/scrawn && bun install && cd ../.. && cd examples && bun install" }, "devDependencies": { "@types/node": "^24.10.0", diff --git a/packages/scrawn/README.md b/packages/scrawn/README.md index 67ea132..428e420 100644 --- a/packages/scrawn/README.md +++ b/packages/scrawn/README.md @@ -1,41 +1,40 @@ -# Scrawn SDK - -## What is Scrawn.js? - -Scrawn.js is the official TypeScript SDK for integrating Scrawn's usage-based billing into your applications. It provides a simple, type-safe interface for tracking usage events and collecting payments via gRPC. - -[View Docs](https://scrawn.vercel.app/docs/scrawn-js) - -## Key Features - -- **Simple API** - Track usage with a single function call -- **Type-Safe** - Full TypeScript support with auto-completion -- **gRPC-Powered** - Built on Connect-RPC for efficient communication -- **Framework Agnostic** - Works with any JavaScript framework -- **Middleware Support** - Built-in middleware with whitelist/blacklist patterns - -## Installation - -Install Scrawn.js in your project: - - -```bash -bun add @scrawn/core -``` - -## Quick Example - -```typescript -import { Scrawn } from '@scrawn/core'; - -const scrawn = new Scrawn({ - apiKey: process.env.SCRAWN_KEY as `scrn_${string}`, - baseURL: process.env.SCRAWN_BASE_URL || 'http://localhost:8069', -}); - -// Track a billable event -await scrawn.sdkCallEventConsumer({ - userId: 'user-123', - debitAmount: 100, -}); -``` \ No newline at end of file +# Scrawn SDK + +## What is Scrawn.js? + +Scrawn.js is the official TypeScript SDK for integrating Scrawn's usage-based billing into your applications. It provides a simple, type-safe interface for tracking usage events and collecting payments via gRPC. + +[View Docs](https://scrawn.vercel.app/docs/scrawn-js) + +## Key Features + +- **Simple API** - Track usage with a single function call +- **Type-Safe** - Full TypeScript support with auto-completion +- **gRPC-Powered** - Built on Connect-RPC for efficient communication +- **Framework Agnostic** - Works with any JavaScript framework +- **Middleware Support** - Built-in middleware with whitelist/blacklist patterns + +## Installation + +Install Scrawn.js in your project: + +```bash +bun add @scrawn/core +``` + +## Quick Example + +```typescript +import { Scrawn } from "@scrawn/core"; + +const scrawn = new Scrawn({ + apiKey: process.env.SCRAWN_KEY as `scrn_${string}`, + baseURL: process.env.SCRAWN_BASE_URL || "http://localhost:8069", +}); + +// Track a billable event +await scrawn.sdkCallEventConsumer({ + userId: "user-123", + debitAmount: 100, +}); +``` diff --git a/packages/scrawn/proto b/packages/scrawn/proto index 165648a..83447d5 160000 --- a/packages/scrawn/proto +++ b/packages/scrawn/proto @@ -1 +1 @@ -Subproject commit 165648af77fae75a97d124eca0445debc278dc52 +Subproject commit 83447d560777e189b47ee6c7736b6a013450664c diff --git a/packages/scrawn/src/config.ts b/packages/scrawn/src/config.ts index 46b8f58..42570d2 100644 --- a/packages/scrawn/src/config.ts +++ b/packages/scrawn/src/config.ts @@ -1,39 +1,39 @@ -/** - * Central configuration for the Scrawn SDK. - * All configuration values should be defined here for easy maintenance. - */ - -export const ScrawnConfig = { - /** - * gRPC client configuration - */ - grpc: { - /** - * HTTP version to use for gRPC transport. - * HTTP/2 provides better performance with: - * - Single persistent connection with multiplexing - * - Header compression (HPACK) - * - Binary framing - * - Built-in connection keep-alive - */ - httpVersion: '1.1' as const, - - /** - * Enable gzip compression for request/response bodies. - * Reduces payload size by ~60-80% with minimal CPU overhead. - */ - useCompression: true, - }, - - /** - * Logging configuration - */ - logging: { - /** - * Enable debug logs - */ - enableDebug: false, - }, -} as const; - -export type ScrawnConfig = typeof ScrawnConfig; +/** + * Central configuration for the Scrawn SDK. + * All configuration values should be defined here for easy maintenance. + */ + +export const ScrawnConfig = { + /** + * gRPC client configuration + */ + grpc: { + /** + * HTTP version to use for gRPC transport. + * HTTP/2 provides better performance with: + * - Single persistent connection with multiplexing + * - Header compression (HPACK) + * - Binary framing + * - Built-in connection keep-alive + */ + httpVersion: "1.1" as const, + + /** + * Enable gzip compression for request/response bodies. + * Reduces payload size by ~60-80% with minimal CPU overhead. + */ + useCompression: true, + }, + + /** + * Logging configuration + */ + logging: { + /** + * Enable debug logs + */ + enableDebug: false, + }, +} as const; + +export type ScrawnConfig = typeof ScrawnConfig; diff --git a/packages/scrawn/src/core/auth/apiKeyAuth.ts b/packages/scrawn/src/core/auth/apiKeyAuth.ts index edb1d3f..567bc00 100644 --- a/packages/scrawn/src/core/auth/apiKeyAuth.ts +++ b/packages/scrawn/src/core/auth/apiKeyAuth.ts @@ -1,103 +1,103 @@ -import { AuthBase } from './baseAuth.js'; -import type { Scrawn } from '../scrawn.js'; -import { ScrawnLogger } from '../../utils/logger.js'; -import { ScrawnValidationError } from '../errors/index.js'; - -const log = new ScrawnLogger('Auth'); - -/** - * API key format: sk_ followed by 16 alphanumeric characters - * @example 'sk_abc123def456ghi7' - */ -export type ApiKeyFormat = `scrn_${string}`; - -/** - * Type guard to validate API key format - */ -export function isValidApiKey(key: string): key is ApiKeyFormat { - return /^scrn_[a-zA-Z0-9]{32}$/.test(key); -} - -/** - * Validates and returns a properly typed API key - * @throws Error if the API key format is invalid - */ -export function validateApiKey(key: string): ApiKeyFormat { - if (!isValidApiKey(key)) { - log.error(`Invalid API key format: "${key}".`); - throw new ScrawnValidationError( - 'Invalid API key format. Expected format: scrn_<32 alphanumeric characters>', - { - details: { - providedKey: key.substring(0, 10) + '...', - expectedFormat: 'scrn_<32 alphanumeric characters>' - } - } - ); - } - return key; -} - -/** - * Credentials structure for API key authentication. - */ -export type ApiKeyAuthCreds = { - apiKey: ApiKeyFormat; -}; - -/** - * Simple API key authentication method. - * - * Provides authentication using a static API key. - * This is the default authentication method registered by the SDK. - * - * @example - * ```typescript - * const auth = new ApiKeyAuth('sk_test_...'); - * scrawn.registerAuthMethod('api', auth); - * ``` - */ -export class ApiKeyAuth extends AuthBase { - /** Authentication method identifier */ - name = 'api' as const; - - /** Validated API key */ - private validatedKey: ApiKeyFormat; - - /** - * Creates a new API key authentication instance. - * - * @param apiKey - Your Scrawn API key (format: sk_<16 alphanumeric chars>) - * @throws Error if API key format is invalid - */ - constructor(apiKey: string) { - super(); - this.validatedKey = validateApiKey(apiKey); - } - - /** - * Initialize the API key auth method. - * No additional setup is required for API key authentication. - * - * @param scrawn - The Scrawn SDK instance (optional, unused) - */ - async init(scrawn?: Scrawn) { - // nothing extra for now - return; - } - - /** - * Get the API key credentials. - * - * @returns A promise that resolves to an object containing the API key - * - * @example - * ```typescript - * const creds = await auth.getCreds(); - * // { apiKey: 'sk_abc123def456ghi7' } - * ``` - */ - async getCreds(): Promise { - return { apiKey: this.validatedKey }; - } -} +import { AuthBase } from "./baseAuth.js"; +import type { Scrawn } from "../scrawn.js"; +import { ScrawnLogger } from "../../utils/logger.js"; +import { ScrawnValidationError } from "../errors/index.js"; + +const log = new ScrawnLogger("Auth"); + +/** + * API key format: sk_ followed by 16 alphanumeric characters + * @example 'sk_abc123def456ghi7' + */ +export type ApiKeyFormat = `scrn_${string}`; + +/** + * Type guard to validate API key format + */ +export function isValidApiKey(key: string): key is ApiKeyFormat { + return /^scrn_[a-zA-Z0-9]{32}$/.test(key); +} + +/** + * Validates and returns a properly typed API key + * @throws Error if the API key format is invalid + */ +export function validateApiKey(key: string): ApiKeyFormat { + if (!isValidApiKey(key)) { + log.error(`Invalid API key format: "${key}".`); + throw new ScrawnValidationError( + "Invalid API key format. Expected format: scrn_<32 alphanumeric characters>", + { + details: { + providedKey: key.substring(0, 10) + "...", + expectedFormat: "scrn_<32 alphanumeric characters>", + }, + } + ); + } + return key; +} + +/** + * Credentials structure for API key authentication. + */ +export type ApiKeyAuthCreds = { + apiKey: ApiKeyFormat; +}; + +/** + * Simple API key authentication method. + * + * Provides authentication using a static API key. + * This is the default authentication method registered by the SDK. + * + * @example + * ```typescript + * const auth = new ApiKeyAuth('sk_test_...'); + * scrawn.registerAuthMethod('api', auth); + * ``` + */ +export class ApiKeyAuth extends AuthBase { + /** Authentication method identifier */ + name = "api" as const; + + /** Validated API key */ + private validatedKey: ApiKeyFormat; + + /** + * Creates a new API key authentication instance. + * + * @param apiKey - Your Scrawn API key (format: sk_<16 alphanumeric chars>) + * @throws Error if API key format is invalid + */ + constructor(apiKey: string) { + super(); + this.validatedKey = validateApiKey(apiKey); + } + + /** + * Initialize the API key auth method. + * No additional setup is required for API key authentication. + * + * @param scrawn - The Scrawn SDK instance (optional, unused) + */ + async init(scrawn?: Scrawn) { + // nothing extra for now + return; + } + + /** + * Get the API key credentials. + * + * @returns A promise that resolves to an object containing the API key + * + * @example + * ```typescript + * const creds = await auth.getCreds(); + * // { apiKey: 'sk_abc123def456ghi7' } + * ``` + */ + async getCreds(): Promise { + return { apiKey: this.validatedKey }; + } +} diff --git a/packages/scrawn/src/core/auth/baseAuth.ts b/packages/scrawn/src/core/auth/baseAuth.ts index 66c460a..3f304dd 100644 --- a/packages/scrawn/src/core/auth/baseAuth.ts +++ b/packages/scrawn/src/core/auth/baseAuth.ts @@ -1,96 +1,96 @@ -import type { Scrawn } from '../scrawn.js'; - -/** - * Abstract base class for authentication methods. - * - * All authentication implementations must extend this class and implement the required methods. - * Auth methods are responsible for managing and providing credentials for API requests. - * - * @template TCreds - The type of credentials this auth method returns - * - * @example - * ```typescript - * type MyAuthCreds = { token: string }; - * - * export class MyAuth extends AuthBase { - * name = 'my_auth'; - * - * async init(scrawn: Scrawn) { - * // Setup logic here - * } - * - * async getCreds(): Promise { - * return { token: 'my_token' }; - * } - * } - * ``` - */ -export abstract class AuthBase { - /** - * Unique identifier for this authentication method. - * Used to register and reference the auth method (e.g., 'api', 'oauth'). - */ - abstract name: string; - - /** - * Initialize the authentication method. - * - * Called once during SDK setup. Use this to perform any necessary - * initialization like token validation or refresh. - * - * @param scrawn - The Scrawn SDK instance - * @returns A promise that resolves when initialization is complete - */ - abstract init(scrawn: Scrawn): Promise; - - /** - * Retrieve the current credentials for this authentication method. - * - * This method is called whenever an event needs to authenticate. - * Credentials are cached by the SDK, so this is typically only called once. - * - * @returns A promise that resolves to the credentials object - * - * @example - * ```typescript - * async getCreds(): Promise { - * return { apiKey: this.apiKey }; - * } - * ``` - */ - abstract getCreds(): Promise; - - /** - * Optional hook that runs before each event is processed. - * - * Use this for operations that must happen before every request, - * such as token refresh checks or rate limiting. - * - * @returns A promise that resolves when the pre-run hook completes - * - * @example - * ```typescript - * async preRun() { - * await this.refreshTokenIfNeeded(); - * } - * ``` - */ - preRun?(): Promise; - - /** - * Optional hook that runs after each event is processed. - * - * Use this for cleanup operations or logging that should happen - * after every request completes. - * - * @returns A promise that resolves when the post-run hook completes - * - * @example - * ```typescript - * async postRun() { - * await this.logRequestMetrics(); - * } - * ``` - */ - postRun?(): Promise; -} +import type { Scrawn } from "../scrawn.js"; + +/** + * Abstract base class for authentication methods. + * + * All authentication implementations must extend this class and implement the required methods. + * Auth methods are responsible for managing and providing credentials for API requests. + * + * @template TCreds - The type of credentials this auth method returns + * + * @example + * ```typescript + * type MyAuthCreds = { token: string }; + * + * export class MyAuth extends AuthBase { + * name = 'my_auth'; + * + * async init(scrawn: Scrawn) { + * // Setup logic here + * } + * + * async getCreds(): Promise { + * return { token: 'my_token' }; + * } + * } + * ``` + */ +export abstract class AuthBase { + /** + * Unique identifier for this authentication method. + * Used to register and reference the auth method (e.g., 'api', 'oauth'). + */ + abstract name: string; + + /** + * Initialize the authentication method. + * + * Called once during SDK setup. Use this to perform any necessary + * initialization like token validation or refresh. + * + * @param scrawn - The Scrawn SDK instance + * @returns A promise that resolves when initialization is complete + */ + abstract init(scrawn: Scrawn): Promise; + + /** + * Retrieve the current credentials for this authentication method. + * + * This method is called whenever an event needs to authenticate. + * Credentials are cached by the SDK, so this is typically only called once. + * + * @returns A promise that resolves to the credentials object + * + * @example + * ```typescript + * async getCreds(): Promise { + * return { apiKey: this.apiKey }; + * } + * ``` + */ + abstract getCreds(): Promise; + + /** + * Optional hook that runs before each event is processed. + * + * Use this for operations that must happen before every request, + * such as token refresh checks or rate limiting. + * + * @returns A promise that resolves when the pre-run hook completes + * + * @example + * ```typescript + * async preRun() { + * await this.refreshTokenIfNeeded(); + * } + * ``` + */ + preRun?(): Promise; + + /** + * Optional hook that runs after each event is processed. + * + * Use this for cleanup operations or logging that should happen + * after every request completes. + * + * @returns A promise that resolves when the post-run hook completes + * + * @example + * ```typescript + * async postRun() { + * await this.logRequestMetrics(); + * } + * ``` + */ + postRun?(): Promise; +} diff --git a/packages/scrawn/src/core/errors/index.ts b/packages/scrawn/src/core/errors/index.ts index 159a555..ea1b1c8 100644 --- a/packages/scrawn/src/core/errors/index.ts +++ b/packages/scrawn/src/core/errors/index.ts @@ -1,364 +1,364 @@ -/** - * Comprehensive error handling system for the Scrawn SDK. - * - * Provides structured error classes with rich metadata for better debugging - * and error handling by SDK consumers. - * - * @example - * ```typescript - * try { - * await scrawn.sdkCallEventConsumer({ ... }); - * } catch (error) { - * if (error instanceof ScrawnAuthenticationError) { - * console.error('Auth failed:', error.message); - * // Refresh API key - * } else if (error instanceof ScrawnNetworkError && error.retryable) { - * // Retry the request - * } - * } - * ``` - */ - -/** - * Base error class for all Scrawn SDK errors. - * Extends native Error with additional metadata fields. - */ -export class ScrawnError extends Error { - /** Error code for programmatic error handling */ - public readonly code: string; - - /** Whether this error is retryable */ - public readonly retryable: boolean; - - /** HTTP status code if applicable */ - public readonly statusCode?: number; - - /** Request ID for debugging (if available) */ - public readonly requestId?: string; - - /** Additional error details */ - public readonly details?: Record; - - /** Original error that caused this error (if any) */ - public readonly cause?: Error; - - constructor( - message: string, - options: { - code: string; - retryable?: boolean; - statusCode?: number; - requestId?: string; - details?: Record; - cause?: Error; - } - ) { - super(message); - this.name = 'ScrawnError'; - this.code = options.code; - this.retryable = options.retryable ?? false; - this.statusCode = options.statusCode; - this.requestId = options.requestId; - this.details = options.details; - this.cause = options.cause; - - // Maintains proper stack trace for where our error was thrown - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor); - } - } - - /** - * Convert error to a plain object for logging/serialization. - */ - toJSON() { - return { - name: this.name, - code: this.code, - message: this.message, - retryable: this.retryable, - statusCode: this.statusCode, - requestId: this.requestId, - details: this.details, - stack: this.stack, - }; - } -} - -/** - * Authentication or authorization error (401, 403). - * Thrown when API key is invalid, expired, or lacks permissions. - * - * @example - * ```typescript - * throw new ScrawnAuthenticationError('Invalid API key', { - * statusCode: 401, - * requestId: 'req_123' - * }); - * ``` - */ -export class ScrawnAuthenticationError extends ScrawnError { - constructor( - message: string, - options?: { - statusCode?: number; - requestId?: string; - details?: Record; - cause?: Error; - } - ) { - super(message, { - code: 'AUTHENTICATION_ERROR', - retryable: false, - statusCode: options?.statusCode ?? 401, - requestId: options?.requestId, - details: options?.details, - cause: options?.cause, - }); - this.name = 'ScrawnAuthenticationError'; - } -} - -/** - * Validation error (400). - * Thrown when request payload fails validation. - * - * @example - * ```typescript - * throw new ScrawnValidationError('Invalid userId', { - * details: { field: 'userId', constraint: 'non-empty' } - * }); - * ``` - */ -export class ScrawnValidationError extends ScrawnError { - constructor( - message: string, - options?: { - requestId?: string; - details?: Record; - cause?: Error; - } - ) { - super(message, { - code: 'VALIDATION_ERROR', - retryable: false, - statusCode: 400, - requestId: options?.requestId, - details: options?.details, - cause: options?.cause, - }); - this.name = 'ScrawnValidationError'; - } -} - -/** - * Rate limit error (429). - * Thrown when API rate limits are exceeded. - * - * @example - * ```typescript - * throw new ScrawnRateLimitError('Rate limit exceeded', { - * details: { retryAfter: 60 } - * }); - * ``` - */ -export class ScrawnRateLimitError extends ScrawnError { - /** Seconds to wait before retrying (if provided by API) */ - public readonly retryAfter?: number; - - constructor( - message: string, - options?: { - requestId?: string; - retryAfter?: number; - details?: Record; - cause?: Error; - } - ) { - super(message, { - code: 'RATE_LIMIT_ERROR', - retryable: true, - statusCode: 429, - requestId: options?.requestId, - details: options?.details, - cause: options?.cause, - }); - this.name = 'ScrawnRateLimitError'; - this.retryAfter = options?.retryAfter; - } -} - -/** - * Network-related error (timeout, connection failure, DNS issues). - * These are typically retryable. - * - * @example - * ```typescript - * throw new ScrawnNetworkError('Connection timeout', { - * cause: originalError, - * details: { timeout: 30000 } - * }); - * ``` - */ -export class ScrawnNetworkError extends ScrawnError { - constructor( - message: string, - options?: { - requestId?: string; - details?: Record; - cause?: Error; - } - ) { - super(message, { - code: 'NETWORK_ERROR', - retryable: true, - requestId: options?.requestId, - details: options?.details, - cause: options?.cause, - }); - this.name = 'ScrawnNetworkError'; - } -} - -/** - * API error from the Scrawn backend (5xx or other server errors). - * May be retryable depending on status code. - * - * @example - * ```typescript - * throw new ScrawnAPIError('Internal server error', { - * statusCode: 500, - * retryable: true, - * requestId: 'req_123' - * }); - * ``` - */ -export class ScrawnAPIError extends ScrawnError { - constructor( - message: string, - options: { - statusCode: number; - requestId?: string; - retryable?: boolean; - details?: Record; - cause?: Error; - } - ) { - super(message, { - code: 'API_ERROR', - retryable: options.retryable ?? (options.statusCode >= 500), - statusCode: options.statusCode, - requestId: options.requestId, - details: options.details, - cause: options.cause, - }); - this.name = 'ScrawnAPIError'; - } -} - -/** - * Configuration error (invalid SDK initialization or settings). - * Not retryable - requires code changes. - * - * @example - * ```typescript - * throw new ScrawnConfigError('Invalid baseURL format', { - * details: { baseURL: 'invalid-url' } - * }); - * ``` - */ -export class ScrawnConfigError extends ScrawnError { - constructor( - message: string, - options?: { - details?: Record; - cause?: Error; - } - ) { - super(message, { - code: 'CONFIG_ERROR', - retryable: false, - details: options?.details, - cause: options?.cause, - }); - this.name = 'ScrawnConfigError'; - } -} - -/** - * Helper function to convert gRPC/ConnectRPC errors to Scrawn errors. - * Maps gRPC status codes to appropriate Scrawn error types. - * - * @internal - */ -export function convertGrpcError(error: any, requestId?: string): ScrawnError { - const message = error.message || 'Unknown error occurred'; - const code = error.code; - - // Extract gRPC status code - // ConnectRPC uses Code enum: https://connectrpc.com/docs/web/errors - switch (code) { - case 16: // UNAUTHENTICATED - case 7: // PERMISSION_DENIED - return new ScrawnAuthenticationError(message, { - statusCode: code === 16 ? 401 : 403, - requestId, - cause: error, - }); - - case 3: // INVALID_ARGUMENT - return new ScrawnValidationError(message, { - requestId, - cause: error, - }); - - case 8: // RESOURCE_EXHAUSTED (rate limit) - return new ScrawnRateLimitError(message, { - requestId, - cause: error, - }); - - case 14: // UNAVAILABLE - case 4: // DEADLINE_EXCEEDED - return new ScrawnNetworkError(message, { - requestId, - details: { grpcCode: code }, - cause: error, - }); - - case 13: // INTERNAL - case 2: // UNKNOWN - case 12: // UNIMPLEMENTED - return new ScrawnAPIError(message, { - statusCode: 500, - retryable: code !== 12, // UNIMPLEMENTED is not retryable - requestId, - details: { grpcCode: code }, - cause: error, - }); - - default: - // Generic API error for unknown codes - return new ScrawnAPIError(message, { - statusCode: 500, - retryable: false, - requestId, - details: { grpcCode: code }, - cause: error, - }); - } -} - -/** - * Helper to check if an error is a Scrawn error. - */ -export function isScrawnError(error: unknown): error is ScrawnError { - return error instanceof ScrawnError; -} - -/** - * Helper to check if an error is retryable. - */ -export function isRetryableError(error: unknown): boolean { - return isScrawnError(error) && error.retryable; -} +/** + * Comprehensive error handling system for the Scrawn SDK. + * + * Provides structured error classes with rich metadata for better debugging + * and error handling by SDK consumers. + * + * @example + * ```typescript + * try { + * await scrawn.sdkCallEventConsumer({ ... }); + * } catch (error) { + * if (error instanceof ScrawnAuthenticationError) { + * console.error('Auth failed:', error.message); + * // Refresh API key + * } else if (error instanceof ScrawnNetworkError && error.retryable) { + * // Retry the request + * } + * } + * ``` + */ + +/** + * Base error class for all Scrawn SDK errors. + * Extends native Error with additional metadata fields. + */ +export class ScrawnError extends Error { + /** Error code for programmatic error handling */ + public readonly code: string; + + /** Whether this error is retryable */ + public readonly retryable: boolean; + + /** HTTP status code if applicable */ + public readonly statusCode?: number; + + /** Request ID for debugging (if available) */ + public readonly requestId?: string; + + /** Additional error details */ + public readonly details?: Record; + + /** Original error that caused this error (if any) */ + public readonly cause?: Error; + + constructor( + message: string, + options: { + code: string; + retryable?: boolean; + statusCode?: number; + requestId?: string; + details?: Record; + cause?: Error; + } + ) { + super(message); + this.name = "ScrawnError"; + this.code = options.code; + this.retryable = options.retryable ?? false; + this.statusCode = options.statusCode; + this.requestId = options.requestId; + this.details = options.details; + this.cause = options.cause; + + // Maintains proper stack trace for where our error was thrown + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + /** + * Convert error to a plain object for logging/serialization. + */ + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + retryable: this.retryable, + statusCode: this.statusCode, + requestId: this.requestId, + details: this.details, + stack: this.stack, + }; + } +} + +/** + * Authentication or authorization error (401, 403). + * Thrown when API key is invalid, expired, or lacks permissions. + * + * @example + * ```typescript + * throw new ScrawnAuthenticationError('Invalid API key', { + * statusCode: 401, + * requestId: 'req_123' + * }); + * ``` + */ +export class ScrawnAuthenticationError extends ScrawnError { + constructor( + message: string, + options?: { + statusCode?: number; + requestId?: string; + details?: Record; + cause?: Error; + } + ) { + super(message, { + code: "AUTHENTICATION_ERROR", + retryable: false, + statusCode: options?.statusCode ?? 401, + requestId: options?.requestId, + details: options?.details, + cause: options?.cause, + }); + this.name = "ScrawnAuthenticationError"; + } +} + +/** + * Validation error (400). + * Thrown when request payload fails validation. + * + * @example + * ```typescript + * throw new ScrawnValidationError('Invalid userId', { + * details: { field: 'userId', constraint: 'non-empty' } + * }); + * ``` + */ +export class ScrawnValidationError extends ScrawnError { + constructor( + message: string, + options?: { + requestId?: string; + details?: Record; + cause?: Error; + } + ) { + super(message, { + code: "VALIDATION_ERROR", + retryable: false, + statusCode: 400, + requestId: options?.requestId, + details: options?.details, + cause: options?.cause, + }); + this.name = "ScrawnValidationError"; + } +} + +/** + * Rate limit error (429). + * Thrown when API rate limits are exceeded. + * + * @example + * ```typescript + * throw new ScrawnRateLimitError('Rate limit exceeded', { + * details: { retryAfter: 60 } + * }); + * ``` + */ +export class ScrawnRateLimitError extends ScrawnError { + /** Seconds to wait before retrying (if provided by API) */ + public readonly retryAfter?: number; + + constructor( + message: string, + options?: { + requestId?: string; + retryAfter?: number; + details?: Record; + cause?: Error; + } + ) { + super(message, { + code: "RATE_LIMIT_ERROR", + retryable: true, + statusCode: 429, + requestId: options?.requestId, + details: options?.details, + cause: options?.cause, + }); + this.name = "ScrawnRateLimitError"; + this.retryAfter = options?.retryAfter; + } +} + +/** + * Network-related error (timeout, connection failure, DNS issues). + * These are typically retryable. + * + * @example + * ```typescript + * throw new ScrawnNetworkError('Connection timeout', { + * cause: originalError, + * details: { timeout: 30000 } + * }); + * ``` + */ +export class ScrawnNetworkError extends ScrawnError { + constructor( + message: string, + options?: { + requestId?: string; + details?: Record; + cause?: Error; + } + ) { + super(message, { + code: "NETWORK_ERROR", + retryable: true, + requestId: options?.requestId, + details: options?.details, + cause: options?.cause, + }); + this.name = "ScrawnNetworkError"; + } +} + +/** + * API error from the Scrawn backend (5xx or other server errors). + * May be retryable depending on status code. + * + * @example + * ```typescript + * throw new ScrawnAPIError('Internal server error', { + * statusCode: 500, + * retryable: true, + * requestId: 'req_123' + * }); + * ``` + */ +export class ScrawnAPIError extends ScrawnError { + constructor( + message: string, + options: { + statusCode: number; + requestId?: string; + retryable?: boolean; + details?: Record; + cause?: Error; + } + ) { + super(message, { + code: "API_ERROR", + retryable: options.retryable ?? options.statusCode >= 500, + statusCode: options.statusCode, + requestId: options.requestId, + details: options.details, + cause: options.cause, + }); + this.name = "ScrawnAPIError"; + } +} + +/** + * Configuration error (invalid SDK initialization or settings). + * Not retryable - requires code changes. + * + * @example + * ```typescript + * throw new ScrawnConfigError('Invalid baseURL format', { + * details: { baseURL: 'invalid-url' } + * }); + * ``` + */ +export class ScrawnConfigError extends ScrawnError { + constructor( + message: string, + options?: { + details?: Record; + cause?: Error; + } + ) { + super(message, { + code: "CONFIG_ERROR", + retryable: false, + details: options?.details, + cause: options?.cause, + }); + this.name = "ScrawnConfigError"; + } +} + +/** + * Helper function to convert gRPC/ConnectRPC errors to Scrawn errors. + * Maps gRPC status codes to appropriate Scrawn error types. + * + * @internal + */ +export function convertGrpcError(error: any, requestId?: string): ScrawnError { + const message = error.message || "Unknown error occurred"; + const code = error.code; + + // Extract gRPC status code + // ConnectRPC uses Code enum: https://connectrpc.com/docs/web/errors + switch (code) { + case 16: // UNAUTHENTICATED + case 7: // PERMISSION_DENIED + return new ScrawnAuthenticationError(message, { + statusCode: code === 16 ? 401 : 403, + requestId, + cause: error, + }); + + case 3: // INVALID_ARGUMENT + return new ScrawnValidationError(message, { + requestId, + cause: error, + }); + + case 8: // RESOURCE_EXHAUSTED (rate limit) + return new ScrawnRateLimitError(message, { + requestId, + cause: error, + }); + + case 14: // UNAVAILABLE + case 4: // DEADLINE_EXCEEDED + return new ScrawnNetworkError(message, { + requestId, + details: { grpcCode: code }, + cause: error, + }); + + case 13: // INTERNAL + case 2: // UNKNOWN + case 12: // UNIMPLEMENTED + return new ScrawnAPIError(message, { + statusCode: 500, + retryable: code !== 12, // UNIMPLEMENTED is not retryable + requestId, + details: { grpcCode: code }, + cause: error, + }); + + default: + // Generic API error for unknown codes + return new ScrawnAPIError(message, { + statusCode: 500, + retryable: false, + requestId, + details: { grpcCode: code }, + cause: error, + }); + } +} + +/** + * Helper to check if an error is a Scrawn error. + */ +export function isScrawnError(error: unknown): error is ScrawnError { + return error instanceof ScrawnError; +} + +/** + * Helper to check if an error is retryable. + */ +export function isRetryableError(error: unknown): boolean { + return isScrawnError(error) && error.retryable; +} diff --git a/packages/scrawn/src/core/grpc/callContext.ts b/packages/scrawn/src/core/grpc/callContext.ts index 43fda73..149d896 100644 --- a/packages/scrawn/src/core/grpc/callContext.ts +++ b/packages/scrawn/src/core/grpc/callContext.ts @@ -15,11 +15,11 @@ * individual builders to preserve separation of concerns. */ -import type { Client, Transport } from '@connectrpc/connect'; -import type { ServiceType } from '@bufbuild/protobuf'; -import { createClient } from '@connectrpc/connect'; -import { ScrawnLogger } from '../../utils/logger.js'; -import type { ServiceMethodNames, Headers } from './types.js'; +import type { Client, Transport } from "@connectrpc/connect"; +import type { ServiceType } from "@bufbuild/protobuf"; +import { createClient } from "@connectrpc/connect"; +import { ScrawnLogger } from "../../utils/logger.js"; +import type { ServiceMethodNames, Headers } from "./types.js"; /** * Shared context for a single gRPC call. @@ -32,7 +32,7 @@ import type { ServiceMethodNames, Headers } from './types.js'; */ export class GrpcCallContext< S extends ServiceType, - M extends ServiceMethodNames + M extends ServiceMethodNames, > { /** The typed Connect client for the service */ public readonly client: Client; @@ -95,7 +95,9 @@ export class GrpcCallContext< * Log successful completion of a call (info level). */ logCallSuccess(): void { - this.log.info(`Successfully completed gRPC call to ${String(this.methodName)}`); + this.log.info( + `Successfully completed gRPC call to ${String(this.methodName)}` + ); } /** @@ -106,7 +108,7 @@ export class GrpcCallContext< logCallError(error: unknown): void { this.log.error( `gRPC call to ${String(this.methodName)} failed: ${ - error instanceof Error ? error.message : 'Unknown error' + error instanceof Error ? error.message : "Unknown error" }` ); } diff --git a/packages/scrawn/src/core/grpc/client.ts b/packages/scrawn/src/core/grpc/client.ts index 22ea648..2863d05 100644 --- a/packages/scrawn/src/core/grpc/client.ts +++ b/packages/scrawn/src/core/grpc/client.ts @@ -21,17 +21,17 @@ * ``` */ -import type { Transport } from '@connectrpc/connect'; -import type { ServiceType } from '@bufbuild/protobuf'; -import { createConnectTransport } from '@connectrpc/connect-node'; -import { GrpcCallContext } from './callContext.js'; -import { RequestBuilder } from './requestBuilder.js'; -import { StreamRequestBuilder } from './streamRequestBuilder.js'; -import { ScrawnLogger } from '../../utils/logger.js'; -import type { ServiceMethodNames } from './types.js'; -import { ScrawnConfig } from '../../config.js'; +import type { Transport } from "@connectrpc/connect"; +import type { ServiceType } from "@bufbuild/protobuf"; +import { createConnectTransport } from "@connectrpc/connect-node"; +import { GrpcCallContext } from "./callContext.js"; +import { RequestBuilder } from "./requestBuilder.js"; +import { StreamRequestBuilder } from "./streamRequestBuilder.js"; +import { ScrawnLogger } from "../../utils/logger.js"; +import type { ServiceMethodNames } from "./types.js"; +import { ScrawnConfig } from "../../config.js"; -const log = new ScrawnLogger('GrpcClient'); +const log = new ScrawnLogger("GrpcClient"); /** * Main gRPC client for making type-safe API calls. @@ -97,7 +97,9 @@ export class GrpcClient { useBinaryFormat: true, // Use binary protobuf (smaller than JSON) }); - log.info(`gRPC client initialized with HTTP/${ScrawnConfig.grpc.httpVersion}`); + log.info( + `gRPC client initialized with HTTP/${ScrawnConfig.grpc.httpVersion}` + ); } /** @@ -136,12 +138,14 @@ export class GrpcClient { service: S, method: M ): RequestBuilder { - log.debug(`Creating new request builder for ${service.typeName}.${String(method)}`); + log.debug( + `Creating new request builder for ${service.typeName}.${String(method)}` + ); const ctx = new GrpcCallContext( this.transport, service, method, - 'RequestBuilder' + "RequestBuilder" ); return new RequestBuilder(ctx); } @@ -195,12 +199,14 @@ export class GrpcClient { service: S, method: M ): StreamRequestBuilder { - log.debug(`Creating new stream request builder for ${service.typeName}.${String(method)}`); + log.debug( + `Creating new stream request builder for ${service.typeName}.${String(method)}` + ); const ctx = new GrpcCallContext( this.transport, service, method, - 'StreamRequestBuilder' + "StreamRequestBuilder" ); return new StreamRequestBuilder(ctx); } diff --git a/packages/scrawn/src/core/grpc/index.ts b/packages/scrawn/src/core/grpc/index.ts index 930cdc4..6a8547c 100644 --- a/packages/scrawn/src/core/grpc/index.ts +++ b/packages/scrawn/src/core/grpc/index.ts @@ -7,10 +7,10 @@ * @module grpc */ -export { GrpcClient } from './client.js'; -export { GrpcCallContext } from './callContext.js'; -export { RequestBuilder } from './requestBuilder.js'; -export { StreamRequestBuilder } from './streamRequestBuilder.js'; +export { GrpcClient } from "./client.js"; +export { GrpcCallContext } from "./callContext.js"; +export { RequestBuilder } from "./requestBuilder.js"; +export { StreamRequestBuilder } from "./streamRequestBuilder.js"; export type { ServiceMethodNames, MethodInput, @@ -23,4 +23,4 @@ export type { ClientStreamingMethodNames, ServerStreamingMethodNames, BidiStreamingMethodNames, -} from './types.js'; +} from "./types.js"; diff --git a/packages/scrawn/src/core/grpc/requestBuilder.ts b/packages/scrawn/src/core/grpc/requestBuilder.ts index 7760c04..811c880 100644 --- a/packages/scrawn/src/core/grpc/requestBuilder.ts +++ b/packages/scrawn/src/core/grpc/requestBuilder.ts @@ -21,15 +21,15 @@ * ``` */ -import type { ServiceType } from '@bufbuild/protobuf'; +import type { ServiceType } from "@bufbuild/protobuf"; import type { ServiceMethodNames, MethodInput, MethodOutput, RequestState, UnaryMethodFn, -} from './types.js'; -import type { GrpcCallContext } from './callContext.js'; +} from "./types.js"; +import type { GrpcCallContext } from "./callContext.js"; /** * Builder for constructing type-safe unary gRPC requests. @@ -43,7 +43,7 @@ import type { GrpcCallContext } from './callContext.js'; */ export class RequestBuilder< S extends ServiceType, - M extends ServiceMethodNames + M extends ServiceMethodNames, > { private readonly ctx: GrpcCallContext; private payload: MethodInput | null = null; @@ -108,11 +108,13 @@ export class RequestBuilder< * }) * ``` */ - addPayload(payload: MethodInput extends infer T ? Partial : never): this { + addPayload( + payload: MethodInput extends infer T ? Partial : never + ): this { if (this.state.hasPayload) { throw new Error( - 'Payload has already been set. Cannot add payload multiple times. ' + - 'Create a new request builder if you need to make another call.' + "Payload has already been set. Cannot add payload multiple times. " + + "Create a new request builder if you need to make another call." ); } @@ -145,7 +147,7 @@ export class RequestBuilder< async request(): Promise> { if (!this.state.hasPayload || this.payload === null) { throw new Error( - 'Cannot make request without payload. Call addPayload() first.' + "Cannot make request without payload. Call addPayload() first." ); } @@ -163,7 +165,9 @@ export class RequestBuilder< MethodInput, MethodOutput >; - const response = await method(this.payload, { headers: this.ctx.getHeaders() }); + const response = await method(this.payload, { + headers: this.ctx.getHeaders(), + }); this.ctx.logCallSuccess(); return response; diff --git a/packages/scrawn/src/core/grpc/streamRequestBuilder.ts b/packages/scrawn/src/core/grpc/streamRequestBuilder.ts index 61473c8..592c3be 100644 --- a/packages/scrawn/src/core/grpc/streamRequestBuilder.ts +++ b/packages/scrawn/src/core/grpc/streamRequestBuilder.ts @@ -16,14 +16,14 @@ * ``` */ -import type { ServiceType } from '@bufbuild/protobuf'; +import type { ServiceType } from "@bufbuild/protobuf"; import type { ServiceMethodNames, MethodInput, MethodOutput, ClientStreamingMethodFn, -} from './types.js'; -import type { GrpcCallContext } from './callContext.js'; +} from "./types.js"; +import type { GrpcCallContext } from "./callContext.js"; /** * Builder for constructing type-safe client-streaming gRPC requests. @@ -37,7 +37,7 @@ import type { GrpcCallContext } from './callContext.js'; */ export class StreamRequestBuilder< S extends ServiceType, - M extends ServiceMethodNames + M extends ServiceMethodNames, > { private readonly ctx: GrpcCallContext; @@ -97,7 +97,9 @@ export class StreamRequestBuilder< * ``` */ async stream( - iterable: AsyncIterable extends infer T ? Partial : never> + iterable: AsyncIterable< + MethodInput extends infer T ? Partial : never + > ): Promise> { try { this.ctx.logCallStart(); @@ -108,10 +110,9 @@ export class StreamRequestBuilder< // This is safe because: // 1. methodName is constrained to ServiceMethodNames // 2. MethodInput/MethodOutput are derived from the same service definition - const method = this.ctx.client[this.ctx.methodName] as ClientStreamingMethodFn< - MethodInput, - MethodOutput - >; + const method = this.ctx.client[ + this.ctx.methodName + ] as ClientStreamingMethodFn, MethodOutput>; const response = await method( iterable as AsyncIterable>>, { headers: this.ctx.getHeaders() } diff --git a/packages/scrawn/src/core/grpc/types.ts b/packages/scrawn/src/core/grpc/types.ts index bfbe0da..a66c014 100644 --- a/packages/scrawn/src/core/grpc/types.ts +++ b/packages/scrawn/src/core/grpc/types.ts @@ -1,120 +1,132 @@ -/** - * Core type system for the gRPC client abstraction layer. - * - * This module provides compile-time type safety for all gRPC operations, - * ensuring that payloads, headers, and responses are correctly typed based - * on the service and method being called. - */ - -import type { ServiceType } from '@bufbuild/protobuf'; -import type { MessageType } from '@bufbuild/protobuf'; - -/** - * Extract all method names from a service as a union of string literals. - * This provides autocomplete and compile-time validation of method names. - */ -export type ServiceMethodNames = keyof S['methods'] & string; - -/** - * Get the input MessageType (constructor) for a specific method on a service. - */ -export type MethodInputType< - S extends ServiceType, - M extends ServiceMethodNames -> = S['methods'][M] extends { I: infer I } ? I : never; - -/** - * Get the output MessageType (constructor) for a specific method on a service. - */ -export type MethodOutputType< - S extends ServiceType, - M extends ServiceMethodNames -> = S['methods'][M] extends { O: infer O } ? O : never; - -/** - * Convert a MessageType to its Message instance type. - * This extracts the actual Message type from a MessageType constructor. - */ -export type MessageTypeToMessage = T extends MessageType ? M : never; - -/** - * Get the input Message type for a specific method on a service. - * This is what you'll use for the payload type. - */ -export type MethodInput< - S extends ServiceType, - M extends ServiceMethodNames -> = MessageTypeToMessage>; - -/** - * Get the output Message type for a specific method on a service. - * This is what you'll get back from the request. - */ -export type MethodOutput< - S extends ServiceType, - M extends ServiceMethodNames -> = MessageTypeToMessage>; - -/** - * HTTP headers as key-value pairs. - */ -export interface Headers { - [key: string]: string; -} - -/** - * State tracking for the request builder to prevent duplicate operations. - */ -export interface RequestState { - hasPayload: boolean; -} - -/** - * Represents the kind of a gRPC method. - * Connect RPC method info includes `kind` with these values. - */ -export type MethodKind = 'unary' | 'server_streaming' | 'client_streaming' | 'bidi_streaming'; - -/** - * Extract methods of a specific kind from a service. - * - * @template S - The gRPC service type - * @template K - The method kind to filter by - */ -export type MethodsOfKind< - S extends ServiceType, - K extends MethodKind -> = { - [M in keyof S['methods'] & string]: S['methods'][M] extends { kind: infer MK } - ? MK extends K - ? M - : never - : never; -}[keyof S['methods'] & string]; - -/** - * Extract unary method names from a service. - * Unary methods accept a single request and return a single response. - */ -export type UnaryMethodNames = MethodsOfKind; - -/** - * Extract client-streaming method names from a service. - * Client-streaming methods accept a stream of requests and return a single response. - */ -export type ClientStreamingMethodNames = MethodsOfKind; - -/** - * Extract server-streaming method names from a service. - * Server-streaming methods accept a single request and return a stream of responses. - */ -export type ServerStreamingMethodNames = MethodsOfKind; - +/** + * Core type system for the gRPC client abstraction layer. + * + * This module provides compile-time type safety for all gRPC operations, + * ensuring that payloads, headers, and responses are correctly typed based + * on the service and method being called. + */ + +import type { ServiceType } from "@bufbuild/protobuf"; +import type { MessageType } from "@bufbuild/protobuf"; + +/** + * Extract all method names from a service as a union of string literals. + * This provides autocomplete and compile-time validation of method names. + */ +export type ServiceMethodNames = keyof S["methods"] & + string; + +/** + * Get the input MessageType (constructor) for a specific method on a service. + */ +export type MethodInputType< + S extends ServiceType, + M extends ServiceMethodNames, +> = S["methods"][M] extends { I: infer I } ? I : never; + +/** + * Get the output MessageType (constructor) for a specific method on a service. + */ +export type MethodOutputType< + S extends ServiceType, + M extends ServiceMethodNames, +> = S["methods"][M] extends { O: infer O } ? O : never; + +/** + * Convert a MessageType to its Message instance type. + * This extracts the actual Message type from a MessageType constructor. + */ +export type MessageTypeToMessage = + T extends MessageType ? M : never; + +/** + * Get the input Message type for a specific method on a service. + * This is what you'll use for the payload type. + */ +export type MethodInput< + S extends ServiceType, + M extends ServiceMethodNames, +> = MessageTypeToMessage>; + +/** + * Get the output Message type for a specific method on a service. + * This is what you'll get back from the request. + */ +export type MethodOutput< + S extends ServiceType, + M extends ServiceMethodNames, +> = MessageTypeToMessage>; + +/** + * HTTP headers as key-value pairs. + */ +export interface Headers { + [key: string]: string; +} + +/** + * State tracking for the request builder to prevent duplicate operations. + */ +export interface RequestState { + hasPayload: boolean; +} + +/** + * Represents the kind of a gRPC method. + * Connect RPC method info includes `kind` with these values. + */ +export type MethodKind = + | "unary" + | "server_streaming" + | "client_streaming" + | "bidi_streaming"; + +/** + * Extract methods of a specific kind from a service. + * + * @template S - The gRPC service type + * @template K - The method kind to filter by + */ +export type MethodsOfKind = { + [M in keyof S["methods"] & string]: S["methods"][M] extends { kind: infer MK } + ? MK extends K + ? M + : never + : never; +}[keyof S["methods"] & string]; + +/** + * Extract unary method names from a service. + * Unary methods accept a single request and return a single response. + */ +export type UnaryMethodNames = MethodsOfKind; + +/** + * Extract client-streaming method names from a service. + * Client-streaming methods accept a stream of requests and return a single response. + */ +export type ClientStreamingMethodNames = MethodsOfKind< + S, + "client_streaming" +>; + +/** + * Extract server-streaming method names from a service. + * Server-streaming methods accept a single request and return a stream of responses. + */ +export type ServerStreamingMethodNames = MethodsOfKind< + S, + "server_streaming" +>; + /** * Extract bidirectional-streaming method names from a service. * Bidi-streaming methods accept a stream of requests and return a stream of responses. */ -export type BidiStreamingMethodNames = MethodsOfKind; +export type BidiStreamingMethodNames = MethodsOfKind< + S, + "bidi_streaming" +>; /** * Options passed to gRPC method calls. @@ -131,7 +143,7 @@ export interface GrpcCallOptions { /** * Function signature for a unary gRPC method. * Takes a partial message and options, returns a promise of the response. - * + * * @template I - Input message type * @template O - Output message type */ @@ -143,7 +155,7 @@ export type UnaryMethodFn = ( /** * Function signature for a client-streaming gRPC method. * Takes an async iterable of partial messages and options, returns a promise of the response. - * + * * @template I - Input message type * @template O - Output message type */ @@ -155,21 +167,21 @@ export type ClientStreamingMethodFn = ( /** * Get the method function type from a Client for a specific method. * This properly extracts the callable type for dynamic method access. - * + * * Note: Due to TypeScript limitations with mapped types and dynamic keys, * this type is used with type assertions when accessing client methods * via computed property names. The assertion is safe because: * 1. The method name M is constrained to ServiceMethodNames * 2. The input/output types are derived from the same service definition - * + * * @template S - The gRPC service type * @template M - The method name on the service */ export type ClientMethod< S extends ServiceType, - M extends ServiceMethodNames -> = S['methods'][M] extends { kind: 'unary' } + M extends ServiceMethodNames, +> = S["methods"][M] extends { kind: "unary" } ? UnaryMethodFn, MethodOutput> - : S['methods'][M] extends { kind: 'client_streaming' } - ? ClientStreamingMethodFn, MethodOutput> - : never; + : S["methods"][M] extends { kind: "client_streaming" } + ? ClientStreamingMethodFn, MethodOutput> + : never; diff --git a/packages/scrawn/src/core/pricing/builders.ts b/packages/scrawn/src/core/pricing/builders.ts index 1a7e321..935e434 100644 --- a/packages/scrawn/src/core/pricing/builders.ts +++ b/packages/scrawn/src/core/pricing/builders.ts @@ -1,29 +1,35 @@ /** * Pricing DSL Builder Functions - * + * * This module provides the fluent builder functions for constructing * type-safe pricing expressions. These functions perform light validation * and build the AST that gets serialized to a string for the backend. - * + * * @example * ```typescript * import { add, mul, tag } from '@scrawn/core'; - * + * * // (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents * const expr = add(mul(tag('PREMIUM_CALL'), 3), tag('EXTRA_FEE'), 250); * ``` */ -import type { AmountExpr, TagExpr, OpExpr, PriceExpr, ExprInput } from './types.js'; -import { validateExpr } from './validate.js'; +import type { + AmountExpr, + TagExpr, + OpExpr, + PriceExpr, + ExprInput, +} from "./types.js"; +import { validateExpr } from "./validate.js"; /** * Convert an ExprInput (PriceExpr or number) to a PriceExpr. * Numbers are wrapped as AmountExpr (cents). */ function toExpr(input: ExprInput): PriceExpr { - if (typeof input === 'number') { - return { kind: 'amount', value: input } as const; + if (typeof input === "number") { + return { kind: "amount", value: input } as const; } return input; } @@ -31,18 +37,18 @@ function toExpr(input: ExprInput): PriceExpr { /** * Create a tag reference expression. * Tags are resolved to their cent values by the backend. - * + * * @param name - The name of the price tag (must be non-empty) * @returns A TagExpr referencing the named tag * @throws Error if name is empty or whitespace-only - * + * * @example * ```typescript * const premiumTag = tag('PREMIUM_CALL'); * ``` */ export function tag(name: string): TagExpr { - const expr: TagExpr = { kind: 'tag', name } as const; + const expr: TagExpr = { kind: "tag", name } as const; validateExpr(expr); // Will throw if invalid return expr; } @@ -50,11 +56,11 @@ export function tag(name: string): TagExpr { /** * Create an addition expression. * Adds all arguments together: arg1 + arg2 + arg3 + ... - * + * * @param args - Two or more expressions or numbers (cents) to add * @returns An OpExpr representing the sum * @throws Error if fewer than 2 arguments provided - * + * * @example * ```typescript * // 100 + 200 + tag('BONUS') @@ -63,8 +69,8 @@ export function tag(name: string): TagExpr { */ export function add(...args: ExprInput[]): OpExpr { const expr: OpExpr = { - kind: 'op', - op: 'ADD', + kind: "op", + op: "ADD", args: args.map(toExpr), } as const; validateExpr(expr); @@ -74,11 +80,11 @@ export function add(...args: ExprInput[]): OpExpr { /** * Create a subtraction expression. * Subtracts subsequent arguments from the first: arg1 - arg2 - arg3 - ... - * + * * @param args - Two or more expressions or numbers (cents) to subtract * @returns An OpExpr representing the difference * @throws Error if fewer than 2 arguments provided - * + * * @example * ```typescript * // tag('TOTAL') - 50 @@ -87,8 +93,8 @@ export function add(...args: ExprInput[]): OpExpr { */ export function sub(...args: ExprInput[]): OpExpr { const expr: OpExpr = { - kind: 'op', - op: 'SUB', + kind: "op", + op: "SUB", args: args.map(toExpr), } as const; validateExpr(expr); @@ -98,11 +104,11 @@ export function sub(...args: ExprInput[]): OpExpr { /** * Create a multiplication expression. * Multiplies all arguments together: arg1 * arg2 * arg3 * ... - * + * * @param args - Two or more expressions or numbers (cents) to multiply * @returns An OpExpr representing the product * @throws Error if fewer than 2 arguments provided - * + * * @example * ```typescript * // tag('PER_TOKEN') * 100 @@ -111,8 +117,8 @@ export function sub(...args: ExprInput[]): OpExpr { */ export function mul(...args: ExprInput[]): OpExpr { const expr: OpExpr = { - kind: 'op', - op: 'MUL', + kind: "op", + op: "MUL", args: args.map(toExpr), } as const; validateExpr(expr); @@ -122,14 +128,14 @@ export function mul(...args: ExprInput[]): OpExpr { /** * Create a division expression. * Divides the first argument by subsequent arguments: arg1 / arg2 / arg3 / ... - * + * * Note: The backend performs integer division. Results are truncated, not rounded. - * + * * @param args - Two or more expressions or numbers (cents) to divide * @returns An OpExpr representing the quotient * @throws Error if fewer than 2 arguments provided * @throws Error if any literal divisor is 0 (detected at build time) - * + * * @example * ```typescript * // tag('TOTAL') / 2 @@ -138,8 +144,8 @@ export function mul(...args: ExprInput[]): OpExpr { */ export function div(...args: ExprInput[]): OpExpr { const expr: OpExpr = { - kind: 'op', - op: 'DIV', + kind: "op", + op: "DIV", args: args.map(toExpr), } as const; validateExpr(expr); @@ -150,18 +156,18 @@ export function div(...args: ExprInput[]): OpExpr { * Create an amount expression from a number of cents. * This is useful when you need to explicitly create an AmountExpr * rather than relying on auto-conversion. - * + * * @param cents - The amount in cents (must be an integer) * @returns An AmountExpr representing the amount * @throws Error if cents is not a finite integer - * + * * @example * ```typescript * const fee = amount(250); // 250 cents = $2.50 * ``` */ export function amount(cents: number): AmountExpr { - const expr: AmountExpr = { kind: 'amount', value: cents } as const; + const expr: AmountExpr = { kind: "amount", value: cents } as const; validateExpr(expr); return expr; } diff --git a/packages/scrawn/src/core/pricing/index.ts b/packages/scrawn/src/core/pricing/index.ts index 183794a..0ca524f 100644 --- a/packages/scrawn/src/core/pricing/index.ts +++ b/packages/scrawn/src/core/pricing/index.ts @@ -1,19 +1,19 @@ /** * Pricing DSL Module - * + * * Provides a type-safe DSL for building pricing expressions that combine * literal amounts, named price tags, and arithmetic operations. - * + * * @example * ```typescript * import { add, mul, tag, serializeExpr } from '@scrawn/core'; - * + * * // Build expression: (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents * const expr = add(mul(tag('PREMIUM_CALL'), 3), tag('EXTRA_FEE'), 250); - * + * * // Serialize for backend: "add(mul(tag('PREMIUM_CALL'),3),tag('EXTRA_FEE'),250)" * const exprString = serializeExpr(expr); - * + * * // Use in event payload * await scrawn.sdkCallEventConsumer({ * userId: 'u123', @@ -30,13 +30,17 @@ export type { OpExpr, PriceExpr, ExprInput, -} from './types.js'; +} from "./types.js"; // Export builder functions -export { tag, add, sub, mul, div, amount } from './builders.js'; +export { tag, add, sub, mul, div, amount } from "./builders.js"; // Export serialization -export { serializeExpr, prettyPrintExpr } from './serialize.js'; +export { serializeExpr, prettyPrintExpr } from "./serialize.js"; // Export validation -export { validateExpr, isValidExpr, PricingExpressionError } from './validate.js'; +export { + validateExpr, + isValidExpr, + PricingExpressionError, +} from "./validate.js"; diff --git a/packages/scrawn/src/core/pricing/serialize.ts b/packages/scrawn/src/core/pricing/serialize.ts index 2649396..d60ccc8 100644 --- a/packages/scrawn/src/core/pricing/serialize.ts +++ b/packages/scrawn/src/core/pricing/serialize.ts @@ -1,43 +1,43 @@ /** * Pricing DSL Serialization - * + * * This module converts typed pricing expression ASTs into string format * that the backend can parse and evaluate. - * + * * Output format examples: * - Amount: 250 - * - Tag: tag('PREMIUM_CALL') - * - Addition: add(100,tag('FEE'),250) - * - Complex: add(mul(tag('PREMIUM_CALL'),3),tag('EXTRA_FEE'),250) - * + * - Tag: tag(PREMIUM_CALL) + * - Addition: add(100,tag(FEE),250) + * - Complex: add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250) + * * The format is designed to be: * - Unambiguous (parseable by the backend) * - Human-readable (for debugging) - * - Compact (minimal whitespace) + * - Compact (minimal whitespace, no quotes around tag names) */ -import type { PriceExpr, AmountExpr, TagExpr, OpExpr } from './types.js'; +import type { PriceExpr, AmountExpr, TagExpr, OpExpr } from "./types.js"; /** * Serialize a pricing expression to a string. - * + * * @param expr - The expression to serialize * @returns A string representation that the backend can parse - * + * * @example * ```typescript * const expr = add(mul(tag('PREMIUM'), 3), 100); * const str = serializeExpr(expr); - * // "add(mul(tag('PREMIUM'),3),100)" + * // "add(mul(tag(PREMIUM),3),100)" * ``` */ export function serializeExpr(expr: PriceExpr): string { switch (expr.kind) { - case 'amount': + case "amount": return serializeAmount(expr); - case 'tag': + case "tag": return serializeTag(expr); - case 'op': + case "op": return serializeOp(expr); } } @@ -52,13 +52,11 @@ function serializeAmount(expr: AmountExpr): string { /** * Serialize a tag expression. - * Format: tag('TAG_NAME') - * Uses single quotes for the tag name. + * Format: tag(TAG_NAME) + * Tag names are unquoted (validation ensures valid identifier format). */ function serializeTag(expr: TagExpr): string { - // Escape single quotes in tag name (though validation should prevent this) - const escapedName = expr.name.replace(/'/g, "\\'"); - return `tag('${escapedName}')`; + return `tag(${expr.name})`; } /** @@ -67,25 +65,25 @@ function serializeTag(expr: TagExpr): string { */ function serializeOp(expr: OpExpr): string { const opName = expr.op.toLowerCase(); - const args = expr.args.map(serializeExpr).join(','); + const args = expr.args.map(serializeExpr).join(","); return `${opName}(${args})`; } /** * Pretty-print a pricing expression with indentation. * Useful for debugging and logging. - * + * * @param expr - The expression to format * @param indent - Number of spaces for indentation (default 2) * @returns A formatted, multi-line string representation - * + * * @example * ```typescript * const expr = add(mul(tag('PREMIUM'), 3), 100); * console.log(prettyPrintExpr(expr)); * // add( * // mul( - * // tag('PREMIUM'), + * // tag(PREMIUM), * // 3 * // ), * // 100 @@ -96,27 +94,31 @@ export function prettyPrintExpr(expr: PriceExpr, indent: number = 2): string { return prettyPrintInternal(expr, 0, indent); } -function prettyPrintInternal(expr: PriceExpr, level: number, indent: number): string { - const pad = ' '.repeat(level * indent); - +function prettyPrintInternal( + expr: PriceExpr, + level: number, + indent: number +): string { + const pad = " ".repeat(level * indent); + switch (expr.kind) { - case 'amount': + case "amount": return expr.value.toString(); - case 'tag': - return `tag('${expr.name}')`; - case 'op': { + case "tag": + return `tag(${expr.name})`; + case "op": { const opName = expr.op.toLowerCase(); if (expr.args.length === 0) { return `${opName}()`; } - + const args = expr.args - .map(arg => { + .map((arg) => { const inner = prettyPrintInternal(arg, level + 1, indent); - return ' '.repeat((level + 1) * indent) + inner; + return " ".repeat((level + 1) * indent) + inner; }) - .join(',\n'); - + .join(",\n"); + return `${opName}(\n${args}\n${pad})`; } } diff --git a/packages/scrawn/src/core/pricing/types.ts b/packages/scrawn/src/core/pricing/types.ts index 3323eaa..8d6e3db 100644 --- a/packages/scrawn/src/core/pricing/types.ts +++ b/packages/scrawn/src/core/pricing/types.ts @@ -1,14 +1,14 @@ /** * Pricing DSL Types - * + * * This module defines the type-safe AST for pricing expressions. * The SDK builds typed expressions using these types, then serializes * them to strings for the backend to parse and evaluate. - * + * * @example * ```typescript * import { add, mul, tag } from '@scrawn/core'; - * + * * // Build a pricing expression: (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents * const expr = add(mul(tag('PREMIUM_CALL'), 3), tag('EXTRA_FEE'), 250); * ``` @@ -17,13 +17,13 @@ /** * Supported arithmetic operations for pricing expressions. */ -export type OpType = 'ADD' | 'SUB' | 'MUL' | 'DIV'; +export type OpType = "ADD" | "SUB" | "MUL" | "DIV"; /** * A literal amount in cents (must be an integer). */ export interface AmountExpr { - readonly kind: 'amount'; + readonly kind: "amount"; readonly value: number; } @@ -31,7 +31,7 @@ export interface AmountExpr { * A reference to a named price tag (resolved by the backend). */ export interface TagExpr { - readonly kind: 'tag'; + readonly kind: "tag"; readonly name: string; } @@ -39,7 +39,7 @@ export interface TagExpr { * An arithmetic operation combining multiple expressions. */ export interface OpExpr { - readonly kind: 'op'; + readonly kind: "op"; readonly op: OpType; readonly args: readonly PriceExpr[]; } diff --git a/packages/scrawn/src/core/pricing/validate.ts b/packages/scrawn/src/core/pricing/validate.ts index ee335b2..af7aa79 100644 --- a/packages/scrawn/src/core/pricing/validate.ts +++ b/packages/scrawn/src/core/pricing/validate.ts @@ -1,17 +1,17 @@ /** * Pricing DSL Validation - * + * * This module provides light SDK-side validation for pricing expressions. * The backend performs full validation; the SDK only catches obvious errors * early to provide better developer experience. - * + * * SDK validates: * - Division by literal zero * - Non-integer cents (amounts must be integers) * - Non-finite numbers (NaN, Infinity) * - Empty operation arguments (ops need at least 2 args) * - Empty/whitespace tag names - * + * * SDK does NOT validate: * - Tag existence (backend resolves tags) * - Division by zero when divisor is a tag (backend handles) @@ -19,7 +19,7 @@ * - Negative results (backend handles) */ -import type { PriceExpr, OpExpr } from './types.js'; +import type { PriceExpr, OpExpr } from "./types.js"; /** * Error thrown when a pricing expression fails validation. @@ -27,26 +27,26 @@ import type { PriceExpr, OpExpr } from './types.js'; export class PricingExpressionError extends Error { constructor(message: string) { super(message); - this.name = 'PricingExpressionError'; + this.name = "PricingExpressionError"; } } /** * Validate a pricing expression. * Throws PricingExpressionError if validation fails. - * + * * @param expr - The expression to validate * @throws PricingExpressionError if validation fails */ export function validateExpr(expr: PriceExpr): void { switch (expr.kind) { - case 'amount': + case "amount": validateAmount(expr.value); break; - case 'tag': + case "tag": validateTagName(expr.name); break; - case 'op': + case "op": validateOp(expr); break; } @@ -65,7 +65,7 @@ function validateAmount(value: number): void { if (!Number.isInteger(value)) { throw new PricingExpressionError( `Amount must be an integer (cents), got: ${value}. ` + - `Hint: Use cents instead of dollars (e.g., 250 instead of 2.50)` + `Hint: Use cents instead of dollars (e.g., 250 instead of 2.50)` ); } } @@ -75,13 +75,13 @@ function validateAmount(value: number): void { * Must be a non-empty string with no leading/trailing whitespace. */ function validateTagName(name: string): void { - if (typeof name !== 'string') { + if (typeof name !== "string") { throw new PricingExpressionError( `Tag name must be a string, got: ${typeof name}` ); } if (name.length === 0) { - throw new PricingExpressionError('Tag name cannot be empty'); + throw new PricingExpressionError("Tag name cannot be empty"); } if (name.trim() !== name) { throw new PricingExpressionError( @@ -89,13 +89,13 @@ function validateTagName(name: string): void { ); } if (name.trim().length === 0) { - throw new PricingExpressionError('Tag name cannot be only whitespace'); + throw new PricingExpressionError("Tag name cannot be only whitespace"); } // Validate tag name format: alphanumeric, underscores, hyphens if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) { throw new PricingExpressionError( `Tag name must start with a letter or underscore and contain only ` + - `alphanumeric characters, underscores, or hyphens: "${name}"` + `alphanumeric characters, underscores, or hyphens: "${name}"` ); } } @@ -107,25 +107,25 @@ function validateTagName(name: string): void { */ function validateOp(expr: OpExpr): void { const { op, args } = expr; - + // Must have at least 2 arguments if (args.length < 2) { throw new PricingExpressionError( `Operation ${op.toLowerCase()} requires at least 2 arguments, got: ${args.length}` ); } - + // Recursively validate all arguments for (const arg of args) { validateExpr(arg); } - + // Check for division by literal zero - if (op === 'DIV') { + if (op === "DIV") { // Check all divisors (all args after the first) for (let i = 1; i < args.length; i++) { const arg = args[i]; - if (arg.kind === 'amount' && arg.value === 0) { + if (arg.kind === "amount" && arg.value === 0) { throw new PricingExpressionError( `Division by zero: divisor at position ${i + 1} is 0` ); @@ -137,7 +137,7 @@ function validateOp(expr: OpExpr): void { /** * Check if an expression is valid without throwing. * Returns true if valid, false otherwise. - * + * * @param expr - The expression to check * @returns true if the expression is valid */ diff --git a/packages/scrawn/src/core/scrawn.ts b/packages/scrawn/src/core/scrawn.ts index 95e1b31..7c0d0a9 100644 --- a/packages/scrawn/src/core/scrawn.ts +++ b/packages/scrawn/src/core/scrawn.ts @@ -1,174 +1,195 @@ -import type { AuthBase } from './auth/baseAuth.js'; -import type { - EventPayload, - MiddlewareRequest, - MiddlewareResponse, +import type { AuthBase } from "./auth/baseAuth.js"; +import type { + EventPayload, + MiddlewareRequest, + MiddlewareResponse, MiddlewareNext, MiddlewareEventConfig, - AITokenUsagePayload -} from './types/event.js'; -import type { AuthRegistry, AuthMethodName, AllCredentials } from './types/auth.js'; -import { ApiKeyAuth } from './auth/apiKeyAuth.js'; -import { ScrawnLogger } from '../utils/logger.js'; -import { matchPath } from '../utils/pathMatcher.js'; -import { forkAsyncIterable } from '../utils/forkAsyncIterable.js'; -import { EventPayloadSchema, AITokenUsagePayloadSchema } from './types/event.js'; -import { GrpcClient } from './grpc/index.js'; -import { EventService } from '../gen/event/v1/event_connect.js'; -import { EventType, SDKCallType, SDKCall, AITokenUsage } from '../gen/event/v1/event_pb.js'; -import type { StreamEventResponse } from '../gen/event/v1/event_pb.js'; -import { PaymentService } from '../gen/payment/v1/payment_connect.js'; -import { - ScrawnConfigError, + AITokenUsagePayload, +} from "./types/event.js"; +import type { + AuthRegistry, + AuthMethodName, + AllCredentials, +} from "./types/auth.js"; +import { ApiKeyAuth } from "./auth/apiKeyAuth.js"; +import { ScrawnLogger } from "../utils/logger.js"; +import { matchPath } from "../utils/pathMatcher.js"; +import { forkAsyncIterable } from "../utils/forkAsyncIterable.js"; +import { + EventPayloadSchema, + AITokenUsagePayloadSchema, +} from "./types/event.js"; +import { GrpcClient } from "./grpc/index.js"; +import { EventService } from "../gen/event/v1/event_connect.js"; +import { + EventType, + SDKCallType, + SDKCall, + AITokenUsage, +} from "../gen/event/v1/event_pb.js"; +import type { StreamEventResponse } from "../gen/event/v1/event_pb.js"; +import { PaymentService } from "../gen/payment/v1/payment_connect.js"; +import { + ScrawnConfigError, ScrawnValidationError, - convertGrpcError -} from './errors/index.js'; -import { serializeExpr } from './pricing/index.js'; - -const log = new ScrawnLogger('Scrawn'); - -/** - * Main SDK class for Scrawn billing infrastructure. - * - * Manages authentication, event tracking, and credential caching. - * All event consumption methods are available directly on the SDK instance. - * - * @example - * ```typescript - * import { Scrawn } from '@scrawn/core'; - * - * // Initialize SDK - * const scrawn = new Scrawn({ apiKey: process.env.SCRAWN_KEY }); - * await scrawn.init(); - * - * // Track SDK calls with direct amount - * await scrawn.sdkCallEventConsumer({ userId: 'u123', debitAmount: 3 }); - * - * // Track SDK calls with price tag - * await scrawn.sdkCallEventConsumer({ userId: 'u123', debitTag: 'PREMIUM_FEATURE' }); - * ``` - */ -export class Scrawn { - /** Map of authentication method names to their implementations */ - private authMethods = new Map>(); - - /** - * Cache of credentials keyed by auth method name for performance. - * Keys are restricted to registered auth method names only. - */ - private credCache = new Map(); - - /** API key used for default authentication */ - private apiKey: AllCredentials['apiKey']; - - /** gRPC client for making type-safe API calls */ - private grpcClient: GrpcClient; - - /** - * Creates a new Scrawn SDK instance. - * - * @param config - Configuration object - * @param config.apiKey - Your Scrawn API key for authentication - * @param config.baseURL - Base URL for the Scrawn API (e.g., 'https://api.scrawn.dev') - * - * @example - * ```typescript - * const scrawn = new Scrawn({ - * apiKey: 'sk_test_...', - * baseURL: 'https://api.scrawn.dev' - * }); - * await scrawn.init(); - * ``` - */ - constructor(config: { apiKey: AllCredentials['apiKey']; baseURL: string }) { - try { - // Validate configuration - if (!config.apiKey || typeof config.apiKey !== 'string') { - throw new ScrawnConfigError('API key is required and must be a string', { - details: { provided: typeof config.apiKey } - }); - } - - if (!config.baseURL || typeof config.baseURL !== 'string') { - throw new ScrawnConfigError('baseURL is required and must be a string', { - details: { provided: typeof config.baseURL } - }); - } - - this.apiKey = config.apiKey; - this.grpcClient = new GrpcClient(config.baseURL); - this.registerAuthMethod('api', new ApiKeyAuth(this.apiKey)); - } catch (error) { - log.error('Failed to initialize Scrawn SDK'); - throw error; - } - } - - /** - * Register an authentication method with the SDK. - * - * Auth methods handle credential management and can be shared across multiple event types. - * Only auth method names defined in AuthRegistry are allowed. - * - * @param name - Unique identifier for this auth method (must be in AuthRegistry) - * @param auth - Instance of an AuthBase implementation - * - * @example - * ```typescript - * scrawn.registerAuthMethod('api', new ApiKeyAuth('sk_test_...')); - * ``` - */ - private registerAuthMethod( - name: K, - auth: AuthBase - ) { - this.authMethods.set(name, auth as AuthBase); - } - - /** - * Get credentials for a specific authentication method. - * - * Credentials are cached after the first fetch for performance. - * Subsequent calls return the cached value without re-fetching. - * Only auth method names defined in AuthRegistry are allowed. - * - * @param authMethodName - Name of the auth method to get credentials for (must be in AuthRegistry) - * @returns A promise that resolves to the credentials object - * @throws Error if the auth method is not registered - * - * @example - * ```typescript - * const creds = await scrawn.getCredsFor('api'); - * // { apiKey: 'sk_test_...' } - * ``` - */ - private async getCredsFor( - authMethodName: K - ): Promise { - // Check cache first - if (this.credCache.has(authMethodName)) { - return this.credCache.get(authMethodName)! as AuthRegistry[K]; - } - - // Get fresh creds from auth method - const auth = this.authMethods.get(authMethodName); - if (!auth) { - throw new ScrawnConfigError(`No auth method registered: ${authMethodName}`, { - details: { requestedMethod: authMethodName } - }); - } - - const creds = await auth.getCreds(); - this.credCache.set(authMethodName, creds); - return creds as AuthRegistry[K]; - } - + convertGrpcError, +} from "./errors/index.js"; +import { serializeExpr } from "./pricing/index.js"; + +const log = new ScrawnLogger("Scrawn"); + +/** + * Main SDK class for Scrawn billing infrastructure. + * + * Manages authentication, event tracking, and credential caching. + * All event consumption methods are available directly on the SDK instance. + * + * @example + * ```typescript + * import { Scrawn } from '@scrawn/core'; + * + * // Initialize SDK + * const scrawn = new Scrawn({ apiKey: process.env.SCRAWN_KEY }); + * await scrawn.init(); + * + * // Track SDK calls with direct amount + * await scrawn.sdkCallEventConsumer({ userId: 'u123', debitAmount: 3 }); + * + * // Track SDK calls with price tag + * await scrawn.sdkCallEventConsumer({ userId: 'u123', debitTag: 'PREMIUM_FEATURE' }); + * ``` + */ +export class Scrawn { + /** Map of authentication method names to their implementations */ + private authMethods = new Map>(); + + /** + * Cache of credentials keyed by auth method name for performance. + * Keys are restricted to registered auth method names only. + */ + private credCache = new Map(); + + /** API key used for default authentication */ + private apiKey: AllCredentials["apiKey"]; + + /** gRPC client for making type-safe API calls */ + private grpcClient: GrpcClient; + + /** + * Creates a new Scrawn SDK instance. + * + * @param config - Configuration object + * @param config.apiKey - Your Scrawn API key for authentication + * @param config.baseURL - Base URL for the Scrawn API (e.g., 'https://api.scrawn.dev') + * + * @example + * ```typescript + * const scrawn = new Scrawn({ + * apiKey: 'sk_test_...', + * baseURL: 'https://api.scrawn.dev' + * }); + * await scrawn.init(); + * ``` + */ + constructor(config: { apiKey: AllCredentials["apiKey"]; baseURL: string }) { + try { + // Validate configuration + if (!config.apiKey || typeof config.apiKey !== "string") { + throw new ScrawnConfigError( + "API key is required and must be a string", + { + details: { provided: typeof config.apiKey }, + } + ); + } + + if (!config.baseURL || typeof config.baseURL !== "string") { + throw new ScrawnConfigError( + "baseURL is required and must be a string", + { + details: { provided: typeof config.baseURL }, + } + ); + } + + this.apiKey = config.apiKey; + this.grpcClient = new GrpcClient(config.baseURL); + this.registerAuthMethod("api", new ApiKeyAuth(this.apiKey)); + } catch (error) { + log.error("Failed to initialize Scrawn SDK"); + throw error; + } + } + + /** + * Register an authentication method with the SDK. + * + * Auth methods handle credential management and can be shared across multiple event types. + * Only auth method names defined in AuthRegistry are allowed. + * + * @param name - Unique identifier for this auth method (must be in AuthRegistry) + * @param auth - Instance of an AuthBase implementation + * + * @example + * ```typescript + * scrawn.registerAuthMethod('api', new ApiKeyAuth('sk_test_...')); + * ``` + */ + private registerAuthMethod( + name: K, + auth: AuthBase + ) { + this.authMethods.set(name, auth as AuthBase); + } + + /** + * Get credentials for a specific authentication method. + * + * Credentials are cached after the first fetch for performance. + * Subsequent calls return the cached value without re-fetching. + * Only auth method names defined in AuthRegistry are allowed. + * + * @param authMethodName - Name of the auth method to get credentials for (must be in AuthRegistry) + * @returns A promise that resolves to the credentials object + * @throws Error if the auth method is not registered + * + * @example + * ```typescript + * const creds = await scrawn.getCredsFor('api'); + * // { apiKey: 'sk_test_...' } + * ``` + */ + private async getCredsFor( + authMethodName: K + ): Promise { + // Check cache first + if (this.credCache.has(authMethodName)) { + return this.credCache.get(authMethodName)! as AuthRegistry[K]; + } + + // Get fresh creds from auth method + const auth = this.authMethods.get(authMethodName); + if (!auth) { + throw new ScrawnConfigError( + `No auth method registered: ${authMethodName}`, + { + details: { requestedMethod: authMethodName }, + } + ); + } + + const creds = await auth.getCreds(); + this.credCache.set(authMethodName, creds); + return creds as AuthRegistry[K]; + } + /** * Track an SDK call event. - * + * * Records SDK usage to the Scrawn backend for billing tracking. * The event is authenticated using the API key provided during SDK initialization. - * + * * @param payload - The SDK call data to track * @param payload.userId - Unique identifier of the user making the call * @param payload.debitAmount - (Optional) Direct amount in cents to debit from the user's account @@ -176,23 +197,23 @@ export class Scrawn { * @param payload.debitExpr - (Optional) Pricing expression for complex calculations * @returns A promise that resolves when the event is tracked * @throws Error if payload validation fails or if not exactly one debit field is provided - * + * * @example * ```typescript * import { add, mul, tag } from '@scrawn/core'; - * + * * // Using direct amount (500 cents = $5.00) * await scrawn.sdkCallEventConsumer({ * userId: 'user_abc123', * debitAmount: 500 * }); - * + * * // Using price tag * await scrawn.sdkCallEventConsumer({ * userId: 'user_abc123', * debitTag: 'PREMIUM_FEATURE' * }); - * + * * // Using pricing expression: (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents * await scrawn.sdkCallEventConsumer({ * userId: 'user_abc123', @@ -200,253 +221,286 @@ export class Scrawn { * }); * ``` */ - async sdkCallEventConsumer(payload: EventPayload): Promise { - const validationResult = EventPayloadSchema.safeParse(payload); - if (!validationResult.success) { - const errors = validationResult.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); - log.error(`Invalid payload for sdkCallEventConsumer: ${errors}`); - throw new ScrawnValidationError('Payload validation failed', { - details: { - errors: validationResult.error.issues.map(e => ({ - field: e.path.join('.'), - message: e.message, - })), - }, - }); - } - - return this.consumeEvent(validationResult.data, 'api', 'SDK_CALL'); - } - - /** - * Create an Express-compatible middleware for tracking API endpoint usage. - * - * This middleware automatically tracks requests to your API endpoints for billing purposes. - * You provide an extractor function that determines the userId and debit info (amount or tag) from each request. - * Optionally, you can provide a whitelist array to only track specific endpoints, - * or a blacklist array to exclude specific endpoints from tracking. - * - * The middleware is framework-agnostic and works with Express, Fastify, and similar frameworks. - * - * @param config - Configuration object for the middleware - * @param config.extractor - Function that extracts userId and debitAmount from the request. Return null to skip tracking. - * @param config.whitelist - Optional array of endpoint patterns to track. Supports wildcards: - * - Exact match: /api/users - * - Single segment (*): /api/* matches /api/users but not /api/users/123 - * - Multi-segment (**): /api/** matches any path starting with /api/ - * - Mixed: /api/star/profile, **.php - * Takes precedence over blacklist. If omitted, all requests will be tracked. - * @param config.blacklist - Optional array of endpoint patterns to exclude. Same wildcard support as whitelist. - * Only applies to endpoints not in the whitelist. - * - * @returns Express-compatible middleware function - * - * @example - * ```typescript - * // Track all endpoints - * app.use(scrawn.middlewareEventConsumer({ - * extractor: (req) => ({ - * userId: req.user.id, - * debitAmount: 1 - * }) - * })); - * - * // Track only specific endpoints with wildcards - * app.use(scrawn.middlewareEventConsumer({ - * extractor: (req) => ({ - * userId: req.headers['x-user-id'] as string, - * debitAmount: req.body.tokens || 1 - * }), - * whitelist: ['/api/generate', '/api/analyze', '/api/v1/*'] - * })); - * - * // Exclude specific endpoints from tracking - * app.use(scrawn.middlewareEventConsumer({ - * extractor: (req) => ({ - * userId: req.user.id, - * debitAmount: 1 - * }), - * blacklist: ['/health', '/api/collect-payment', '/internal/**', '**.tmp'] - * })); - * ``` - */ - middlewareEventConsumer(config: MiddlewareEventConfig) { - return async (req: MiddlewareRequest, res: MiddlewareResponse, next: MiddlewareNext) => { - try { - const requestPath = req.path || req.url || ''; - - // Check whitelist first (takes precedence) - if (config.whitelist && config.whitelist.length > 0) { - const isWhitelisted = config.whitelist.some(pattern => matchPath(requestPath, pattern)); - - if (!isWhitelisted) { - return next(); - } - } - - // Then check blacklist - if (config.blacklist && config.blacklist.length > 0) { - const isBlacklisted = config.blacklist.some(pattern => matchPath(requestPath, pattern)); - - if (isBlacklisted) { - return next(); - } - } - - const extractedPayload = await config.extractor(req); - - // If extractor returns null, skip tracking - if (extractedPayload === null) { - log.warn(`Extractor returned null for path: ${requestPath}. Skipping event tracking.`); - return next(); - } - - const validationResult = EventPayloadSchema.safeParse(extractedPayload); - if (!validationResult.success) { - const errors = validationResult.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); - log.error(`Invalid payload extracted in middlewareEventConsumer: ${errors}`); // TODO: for error shit implement the callback shit - return next(); - } - - this.consumeEvent(validationResult.data, 'api', 'MIDDLEWARE_CALL') - .catch(error => { - log.error(`Failed to track middleware event: ${error.message}`); - }); // TODO: for error shit implement the callback shit - - next(); - } catch (error) { - log.error(`Error in middlewareEventConsumer: ${error instanceof Error ? error.message : 'Unknown error'}`); - next(); - } // TODO: for error shit implement the callback shit - }; - } - - /** - * Collect payment by creating a checkout link for a user. - * - * Generates a payment checkout link for the specified user via the Scrawn payment service. - * The checkout link can be used to direct users to complete their payment. - * - * @param userId - Unique identifier of the user to collect payment from - * @returns A promise that resolves to the checkout link URL - * @throws Error if the gRPC call fails or if authentication is invalid - * - * @example - * ```typescript - * const checkoutLink = await scrawn.collectPayment('user_abc123'); - * // Returns: 'https://checkout.scrawn.dev/...' - * // Redirect user to this URL to complete payment - * ``` - */ - async collectPayment(userId: string): Promise { - // Validate input - if (!userId || typeof userId !== 'string' || userId.trim().length === 0) { - log.error('Invalid userId provided to collectPayment'); - throw new ScrawnValidationError('userId must be a non-empty string', { - details: { provided: typeof userId } - }); - } - - // Get credentials for authentication - const creds = await this.getCredsFor('api'); - - try { - log.info(`Creating checkout link for user: ${userId}`); - - const response = await this.grpcClient - .newCall(PaymentService, 'createCheckoutLink') - .addHeader('Authorization', `Bearer ${creds.apiKey}`) - .addPayload({ userId }) - .request(); - - log.info(`Checkout link created successfully: ${response.checkoutLink}`); - return response.checkoutLink; - } catch (error) { - log.error(`Failed to create checkout link: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw convertGrpcError(error); - } - } - - - - /** - * Internal method to consume and process an event. - * - * This method: - * 1. Validates authentication - * 2. Fetches/caches credentials - * 3. Executes any pre-run hooks - * 4. Processes the event via gRPC call to RegisterEvent - * - * @param payload - Event payload data - * @param authMethodName - Name of the auth method to use (must be in AuthRegistry) - * @param eventType - Type of event for categorization (RAW or MIDDLEWARE_CALL) - * @returns A promise that resolves when the event is processed - * @throws Error if auth method is not registered or gRPC call fails - * - * @internal - */ - private async consumeEvent( - payload: EventPayload, - authMethodName: K, - eventType: 'SDK_CALL' | 'MIDDLEWARE_CALL' - ): Promise { - const auth = this.authMethods.get(authMethodName); - if (!auth) { - throw new ScrawnConfigError(`No auth registered for type ${authMethodName}`, { - details: { requestedAuth: authMethodName } - }); - } - - // Run pre-hook if exists - if (auth.preRun) await auth.preRun(); - - // Get creds (from cache or fresh) - const creds = await this.getCredsFor(authMethodName); - - // Map event type to SDKCallType - const sdkCallType = eventType === 'SDK_CALL' - ? SDKCallType.RAW - : SDKCallType.MIDDLEWARE_CALL; - + async sdkCallEventConsumer(payload: EventPayload): Promise { + const validationResult = EventPayloadSchema.safeParse(payload); + if (!validationResult.success) { + const errors = validationResult.error.issues + .map((e) => `${e.path.join(".")}: ${e.message}`) + .join(", "); + log.error(`Invalid payload for sdkCallEventConsumer: ${errors}`); + throw new ScrawnValidationError("Payload validation failed", { + details: { + errors: validationResult.error.issues.map((e) => ({ + field: e.path.join("."), + message: e.message, + })), + }, + }); + } + + return this.consumeEvent(validationResult.data, "api", "SDK_CALL"); + } + + /** + * Create an Express-compatible middleware for tracking API endpoint usage. + * + * This middleware automatically tracks requests to your API endpoints for billing purposes. + * You provide an extractor function that determines the userId and debit info (amount or tag) from each request. + * Optionally, you can provide a whitelist array to only track specific endpoints, + * or a blacklist array to exclude specific endpoints from tracking. + * + * The middleware is framework-agnostic and works with Express, Fastify, and similar frameworks. + * + * @param config - Configuration object for the middleware + * @param config.extractor - Function that extracts userId and debitAmount from the request. Return null to skip tracking. + * @param config.whitelist - Optional array of endpoint patterns to track. Supports wildcards: + * - Exact match: /api/users + * - Single segment (*): /api/* matches /api/users but not /api/users/123 + * - Multi-segment (**): /api/** matches any path starting with /api/ + * - Mixed: /api/star/profile, **.php + * Takes precedence over blacklist. If omitted, all requests will be tracked. + * @param config.blacklist - Optional array of endpoint patterns to exclude. Same wildcard support as whitelist. + * Only applies to endpoints not in the whitelist. + * + * @returns Express-compatible middleware function + * + * @example + * ```typescript + * // Track all endpoints + * app.use(scrawn.middlewareEventConsumer({ + * extractor: (req) => ({ + * userId: req.user.id, + * debitAmount: 1 + * }) + * })); + * + * // Track only specific endpoints with wildcards + * app.use(scrawn.middlewareEventConsumer({ + * extractor: (req) => ({ + * userId: req.headers['x-user-id'] as string, + * debitAmount: req.body.tokens || 1 + * }), + * whitelist: ['/api/generate', '/api/analyze', '/api/v1/*'] + * })); + * + * // Exclude specific endpoints from tracking + * app.use(scrawn.middlewareEventConsumer({ + * extractor: (req) => ({ + * userId: req.user.id, + * debitAmount: 1 + * }), + * blacklist: ['/health', '/api/collect-payment', '/internal/**', '**.tmp'] + * })); + * ``` + */ + middlewareEventConsumer(config: MiddlewareEventConfig) { + return async ( + req: MiddlewareRequest, + res: MiddlewareResponse, + next: MiddlewareNext + ) => { + try { + const requestPath = req.path || req.url || ""; + + // Check whitelist first (takes precedence) + if (config.whitelist && config.whitelist.length > 0) { + const isWhitelisted = config.whitelist.some((pattern) => + matchPath(requestPath, pattern) + ); + + if (!isWhitelisted) { + return next(); + } + } + + // Then check blacklist + if (config.blacklist && config.blacklist.length > 0) { + const isBlacklisted = config.blacklist.some((pattern) => + matchPath(requestPath, pattern) + ); + + if (isBlacklisted) { + return next(); + } + } + + const extractedPayload = await config.extractor(req); + + // If extractor returns null, skip tracking + if (extractedPayload === null) { + log.warn( + `Extractor returned null for path: ${requestPath}. Skipping event tracking.` + ); + return next(); + } + + const validationResult = EventPayloadSchema.safeParse(extractedPayload); + if (!validationResult.success) { + const errors = validationResult.error.issues + .map((e) => `${e.path.join(".")}: ${e.message}`) + .join(", "); + log.error( + `Invalid payload extracted in middlewareEventConsumer: ${errors}` + ); // TODO: for error shit implement the callback shit + return next(); + } + + this.consumeEvent( + validationResult.data, + "api", + "MIDDLEWARE_CALL" + ).catch((error) => { + log.error(`Failed to track middleware event: ${error.message}`); + }); // TODO: for error shit implement the callback shit + + next(); + } catch (error) { + log.error( + `Error in middlewareEventConsumer: ${error instanceof Error ? error.message : "Unknown error"}` + ); + next(); + } // TODO: for error shit implement the callback shit + }; + } + + /** + * Collect payment by creating a checkout link for a user. + * + * Generates a payment checkout link for the specified user via the Scrawn payment service. + * The checkout link can be used to direct users to complete their payment. + * + * @param userId - Unique identifier of the user to collect payment from + * @returns A promise that resolves to the checkout link URL + * @throws Error if the gRPC call fails or if authentication is invalid + * + * @example + * ```typescript + * const checkoutLink = await scrawn.collectPayment('user_abc123'); + * // Returns: 'https://checkout.scrawn.dev/...' + * // Redirect user to this URL to complete payment + * ``` + */ + async collectPayment(userId: string): Promise { + // Validate input + if (!userId || typeof userId !== "string" || userId.trim().length === 0) { + log.error("Invalid userId provided to collectPayment"); + throw new ScrawnValidationError("userId must be a non-empty string", { + details: { provided: typeof userId }, + }); + } + + // Get credentials for authentication + const creds = await this.getCredsFor("api"); + try { - log.info(`Ingesting event (type: ${eventType}) with creds: ${JSON.stringify(creds)}, payload: ${JSON.stringify(payload)}`); - + log.info(`Creating checkout link for user: ${userId}`); + + const response = await this.grpcClient + .newCall(PaymentService, "createCheckoutLink") + .addHeader("Authorization", `Bearer ${creds.apiKey}`) + .addPayload({ userId }) + .request(); + + log.info(`Checkout link created successfully: ${response.checkoutLink}`); + return response.checkoutLink; + } catch (error) { + log.error( + `Failed to create checkout link: ${error instanceof Error ? error.message : "Unknown error"}` + ); + throw convertGrpcError(error); + } + } + + /** + * Internal method to consume and process an event. + * + * This method: + * 1. Validates authentication + * 2. Fetches/caches credentials + * 3. Executes any pre-run hooks + * 4. Processes the event via gRPC call to RegisterEvent + * + * @param payload - Event payload data + * @param authMethodName - Name of the auth method to use (must be in AuthRegistry) + * @param eventType - Type of event for categorization (RAW or MIDDLEWARE_CALL) + * @returns A promise that resolves when the event is processed + * @throws Error if auth method is not registered or gRPC call fails + * + * @internal + */ + private async consumeEvent( + payload: EventPayload, + authMethodName: K, + eventType: "SDK_CALL" | "MIDDLEWARE_CALL" + ): Promise { + const auth = this.authMethods.get(authMethodName); + if (!auth) { + throw new ScrawnConfigError( + `No auth registered for type ${authMethodName}`, + { + details: { requestedAuth: authMethodName }, + } + ); + } + + // Run pre-hook if exists + if (auth.preRun) await auth.preRun(); + + // Get creds (from cache or fresh) + const creds = await this.getCredsFor(authMethodName); + + // Map event type to SDKCallType + const sdkCallType = + eventType === "SDK_CALL" ? SDKCallType.RAW : SDKCallType.MIDDLEWARE_CALL; + + try { + log.info( + `Ingesting event (type: ${eventType}) with creds: ${JSON.stringify(creds)}, payload: ${JSON.stringify(payload)}` + ); + // Build debit field based on which debit option is provided - let debitField: { case: 'amount'; value: number } | { case: 'tag'; value: string } | { case: 'expr'; value: string }; - + let debitField: + | { case: "amount"; value: number } + | { case: "tag"; value: string } + | { case: "expr"; value: string }; + if (payload.debitAmount !== undefined) { - debitField = { case: 'amount' as const, value: payload.debitAmount }; + debitField = { case: "amount" as const, value: payload.debitAmount }; } else if (payload.debitTag !== undefined) { - debitField = { case: 'tag' as const, value: payload.debitTag }; + debitField = { case: "tag" as const, value: payload.debitTag }; } else { // debitExpr is defined (validated by schema) - debitField = { case: 'expr' as const, value: serializeExpr(payload.debitExpr!) }; + debitField = { + case: "expr" as const, + value: serializeExpr(payload.debitExpr!), + }; } - + const response = await this.grpcClient - .newCall(EventService, 'registerEvent') - .addHeader('Authorization', `Bearer ${creds.apiKey}`) - .addPayload({ - type: EventType.SDK_CALL, - userId: payload.userId, - data: { - case: 'sdkCall', - value: new SDKCall({ - sdkCallType: sdkCallType, - debit: debitField, - }), - }, - }) - .request(); - - log.info(`Event registered successfully: ${JSON.stringify(response)}`); - } catch (error) { - log.error(`Failed to register event: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw convertGrpcError(error); - } - -if (auth.postRun) await auth.postRun(); + .newCall(EventService, "registerEvent") + .addHeader("Authorization", `Bearer ${creds.apiKey}`) + .addPayload({ + type: EventType.SDK_CALL, + userId: payload.userId, + data: { + case: "sdkCall", + value: new SDKCall({ + sdkCallType: sdkCallType, + debit: debitField, + }), + }, + }) + .request(); + + log.info(`Event registered successfully: ${JSON.stringify(response)}`); + } catch (error) { + log.error( + `Failed to register event: ${error instanceof Error ? error.message : "Unknown error"}` + ); + throw convertGrpcError(error); + } + + if (auth.postRun) await auth.postRun(); } /** @@ -456,11 +510,11 @@ if (auth.postRun) await auth.postRun(); /** * Stream AI token usage events to the Scrawn backend (fire-and-forget mode). - * + * * Consumes an async iterable of AI token usage payloads and streams them * to the backend for billing tracking. This is designed for real-time * AI token tracking where usage is reported as tokens are consumed. - * + * * @param stream - An async iterable of AI token usage payloads * @returns A promise that resolves to the stream response containing processed event count * @throws Error if authentication fails or the gRPC stream fails @@ -471,7 +525,7 @@ if (auth.postRun) await auth.postRun(); /** * Stream AI token usage events to the Scrawn backend (fire-and-forget mode). - * + * * @param stream - An async iterable of AI token usage payloads * @param config - Configuration with return: false (or omitted) * @returns A promise that resolves to the stream response containing processed event count @@ -483,27 +537,27 @@ if (auth.postRun) await auth.postRun(); /** * Stream AI token usage events to the Scrawn backend while returning a forked stream. - * + * * When `return: true`, the input stream is forked: one fork is sent to the billing * backend (non-blocking), and the other fork is returned to the caller for streaming * to the user. This enables simultaneous billing and user-facing token streaming. - * + * * @param stream - An async iterable of AI token usage payloads * @param config - Configuration with return: true * @returns Object containing the response promise and a forked stream for user consumption - * + * * @example * ```typescript * const { response, stream: userStream } = await scrawn.aiTokenStreamConsumer( * tokenGenerator(), * { return: true } * ); - * + * * // Stream tokens to user while billing happens in background * for await (const token of userStream) { * process.stdout.write(token.outputTokens.toString()); * } - * + * * // Billing completes after stream is consumed * const result = await response; * console.log(`Billed ${result.eventsProcessed} events`); @@ -512,22 +566,25 @@ if (auth.postRun) await auth.postRun(); async aiTokenStreamConsumer( stream: AsyncIterable, config: { return: true } - ): Promise<{ response: Promise; stream: AsyncIterable }>; + ): Promise<{ + response: Promise; + stream: AsyncIterable; + }>; /** * Stream AI token usage events to the Scrawn backend. - * + * * Consumes an async iterable of AI token usage payloads and streams them * to the backend for billing tracking. This is designed for real-time * AI token tracking where usage is reported as tokens are consumed. - * + * * The streaming is non-blocking: the iterable is consumed in the background * and streamed to the server without blocking the caller's code path. - * + * * When `return: true`, the stream is forked internally - one fork goes to * billing (non-blocking), and another is returned to the caller for streaming * to the user. - * + * * @param stream - An async iterable of AI token usage payloads * @param config - Optional configuration object * @param config.return - If true, returns a forked stream alongside the response promise @@ -535,7 +592,7 @@ if (auth.postRun) await auth.postRun(); * - false/undefined: Promise * - true: { response: Promise, stream: AsyncIterable } * @throws Error if authentication fails or the gRPC stream fails - * + * * @example * ```typescript * // Fire-and-forget mode (default) @@ -549,50 +606,60 @@ if (auth.postRun) await auth.postRun(); * outputDebit: { amount: 0.006 } * }; * } - * + * * const response = await scrawn.aiTokenStreamConsumer(tokenUsageStream()); * console.log(`Processed ${response.eventsProcessed} events`); - * + * * // Return mode - stream to user while billing * const { response, stream } = await scrawn.aiTokenStreamConsumer( * tokenUsageStream(), * { return: true } * ); - * + * * for await (const token of stream) { * // Stream to user * } - * + * * const result = await response; * ``` */ async aiTokenStreamConsumer( stream: AsyncIterable, config?: { return?: boolean } - ): Promise; stream: AsyncIterable }> { + ): Promise< + | StreamEventResponse + | { + response: Promise; + stream: AsyncIterable; + } + > { // Get credentials for authentication - const creds = await this.getCredsFor('api'); + const creds = await this.getCredsFor("api"); // If return mode, fork the stream if (config?.return === true) { const [billingStream, userStream] = forkAsyncIterable(stream); - + // Transform billing stream and send to backend (non-blocking) const transformedStream = this.transformAITokenStream(billingStream); - + const responsePromise = (async (): Promise => { try { - log.info('Starting AI token usage stream (return mode)'); + log.info("Starting AI token usage stream (return mode)"); const response = await this.grpcClient - .newStreamCall(EventService, 'streamEvents') - .addHeader('Authorization', `Bearer ${creds.apiKey}`) + .newStreamCall(EventService, "streamEvents") + .addHeader("Authorization", `Bearer ${creds.apiKey}`) .stream(transformedStream); - log.info(`AI token stream completed: ${response.eventsProcessed} events processed`); + log.info( + `AI token stream completed: ${response.eventsProcessed} events processed` + ); return response; } catch (error) { - log.error(`Failed to stream AI token usage: ${error instanceof Error ? error.message : 'Unknown error'}`); + log.error( + `Failed to stream AI token usage: ${error instanceof Error ? error.message : "Unknown error"}` + ); throw convertGrpcError(error); } })(); @@ -604,27 +671,31 @@ if (auth.postRun) await auth.postRun(); const transformedStream = this.transformAITokenStream(stream); try { - log.info('Starting AI token usage stream'); + log.info("Starting AI token usage stream"); const response = await this.grpcClient - .newStreamCall(EventService, 'streamEvents') - .addHeader('Authorization', `Bearer ${creds.apiKey}`) + .newStreamCall(EventService, "streamEvents") + .addHeader("Authorization", `Bearer ${creds.apiKey}`) .stream(transformedStream); - log.info(`AI token stream completed: ${response.eventsProcessed} events processed`); + log.info( + `AI token stream completed: ${response.eventsProcessed} events processed` + ); return response; } catch (error) { - log.error(`Failed to stream AI token usage: ${error instanceof Error ? error.message : 'Unknown error'}`); + log.error( + `Failed to stream AI token usage: ${error instanceof Error ? error.message : "Unknown error"}` + ); throw convertGrpcError(error); } } /** * Transform user-provided AI token usage payloads into StreamEventRequest format. - * + * * Validates each payload and maps it to the gRPC request format. * Invalid payloads are logged and skipped. - * + * * @param stream - The user's async iterable of AITokenUsagePayload * @returns An async iterable of StreamEventRequest payloads * @internal @@ -636,7 +707,9 @@ if (auth.postRun) await auth.postRun(); // Validate each payload const validationResult = AITokenUsagePayloadSchema.safeParse(payload); if (!validationResult.success) { - const errors = validationResult.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); + const errors = validationResult.error.issues + .map((e) => `${e.path.join(".")}: ${e.message}`) + .join(", "); log.error(`Invalid AI token usage payload, skipping: ${errors}`); continue; } @@ -644,30 +717,54 @@ if (auth.postRun) await auth.postRun(); const validated = validationResult.data; // Build input debit field (amount, tag, or expr) - let inputDebit: { case: 'inputAmount'; value: number } | { case: 'inputTag'; value: string } | { case: 'inputExpr'; value: string }; + let inputDebit: + | { case: "inputAmount"; value: number } + | { case: "inputTag"; value: string } + | { case: "inputExpr"; value: string }; if (validated.inputDebit.amount !== undefined) { - inputDebit = { case: 'inputAmount' as const, value: validated.inputDebit.amount }; + inputDebit = { + case: "inputAmount" as const, + value: validated.inputDebit.amount, + }; } else if (validated.inputDebit.tag !== undefined) { - inputDebit = { case: 'inputTag' as const, value: validated.inputDebit.tag }; + inputDebit = { + case: "inputTag" as const, + value: validated.inputDebit.tag, + }; } else { - inputDebit = { case: 'inputExpr' as const, value: serializeExpr(validated.inputDebit.expr!) }; + inputDebit = { + case: "inputExpr" as const, + value: serializeExpr(validated.inputDebit.expr!), + }; } // Build output debit field (amount, tag, or expr) - let outputDebit: { case: 'outputAmount'; value: number } | { case: 'outputTag'; value: string } | { case: 'outputExpr'; value: string }; + let outputDebit: + | { case: "outputAmount"; value: number } + | { case: "outputTag"; value: string } + | { case: "outputExpr"; value: string }; if (validated.outputDebit.amount !== undefined) { - outputDebit = { case: 'outputAmount' as const, value: validated.outputDebit.amount }; + outputDebit = { + case: "outputAmount" as const, + value: validated.outputDebit.amount, + }; } else if (validated.outputDebit.tag !== undefined) { - outputDebit = { case: 'outputTag' as const, value: validated.outputDebit.tag }; + outputDebit = { + case: "outputTag" as const, + value: validated.outputDebit.tag, + }; } else { - outputDebit = { case: 'outputExpr' as const, value: serializeExpr(validated.outputDebit.expr!) }; + outputDebit = { + case: "outputExpr" as const, + value: serializeExpr(validated.outputDebit.expr!), + }; } yield { type: EventType.AI_TOKEN_USAGE, userId: validated.userId, data: { - case: 'aiTokenUsage' as const, + case: "aiTokenUsage" as const, value: new AITokenUsage({ model: validated.model, inputTokens: validated.inputTokens, diff --git a/packages/scrawn/src/core/types/auth.ts b/packages/scrawn/src/core/types/auth.ts index 9cecd4b..e1c2fd0 100644 --- a/packages/scrawn/src/core/types/auth.ts +++ b/packages/scrawn/src/core/types/auth.ts @@ -1,34 +1,34 @@ -import type { ApiKeyAuthCreds } from '../auth/apiKeyAuth.js'; - -/** - * Registry of all authentication methods and their credential types. - * - * When you add a new auth method, add it here: - * - Key: The auth method name (literal string) - * - Value: The credential type for that auth method - * - * @example - * ```typescript - * // Adding OAuth - * export type AuthRegistry = { - * api: ApiKeyAuthCreds; - * oauth: OAuthAuthCreds; // Add new auth types here - * }; - * ``` - */ -export type AuthRegistry = { - api: ApiKeyAuthCreds; - // Add new auth methods here as you create them -}; - -/** - * Union of all auth method names. - * Automatically derived from AuthRegistry keys. - */ -export type AuthMethodName = keyof AuthRegistry; - -/** - * Union of all credential types. - * Automatically derived from AuthRegistry values. - */ -export type AllCredentials = AuthRegistry[keyof AuthRegistry]; +import type { ApiKeyAuthCreds } from "../auth/apiKeyAuth.js"; + +/** + * Registry of all authentication methods and their credential types. + * + * When you add a new auth method, add it here: + * - Key: The auth method name (literal string) + * - Value: The credential type for that auth method + * + * @example + * ```typescript + * // Adding OAuth + * export type AuthRegistry = { + * api: ApiKeyAuthCreds; + * oauth: OAuthAuthCreds; // Add new auth types here + * }; + * ``` + */ +export type AuthRegistry = { + api: ApiKeyAuthCreds; + // Add new auth methods here as you create them +}; + +/** + * Union of all auth method names. + * Automatically derived from AuthRegistry keys. + */ +export type AuthMethodName = keyof AuthRegistry; + +/** + * Union of all credential types. + * Automatically derived from AuthRegistry values. + */ +export type AllCredentials = AuthRegistry[keyof AuthRegistry]; diff --git a/packages/scrawn/src/core/types/event.ts b/packages/scrawn/src/core/types/event.ts index 4cbb6cd..103c92b 100644 --- a/packages/scrawn/src/core/types/event.ts +++ b/packages/scrawn/src/core/types/event.ts @@ -1,81 +1,95 @@ -import { z } from 'zod'; -import type { PriceExpr } from '../pricing/types.js'; -import { isValidExpr } from '../pricing/validate.js'; +import { z } from "zod"; +import type { PriceExpr } from "../pricing/types.js"; +import { isValidExpr } from "../pricing/validate.js"; /** * Custom zod schema for PriceExpr validation. * Validates that the value is a valid pricing expression AST. */ const PriceExprSchema = z.custom( - (val): val is PriceExpr => { - if (val === null || val === undefined || typeof val !== 'object') { - return false; - } - const expr = val as PriceExpr; - // Check that it has a valid kind - if (expr.kind !== 'amount' && expr.kind !== 'tag' && expr.kind !== 'op') { - return false; - } - // Use the validation function - return isValidExpr(expr); - }, - { message: 'Must be a valid pricing expression (use tag(), add(), sub(), mul(), div(), or amount())' } + (val): val is PriceExpr => { + if (val === null || val === undefined || typeof val !== "object") { + return false; + } + const expr = val as PriceExpr; + // Check that it has a valid kind + if (expr.kind !== "amount" && expr.kind !== "tag" && expr.kind !== "op") { + return false; + } + // Use the validation function + return isValidExpr(expr); + }, + { + message: + "Must be a valid pricing expression (use tag(), add(), sub(), mul(), div(), or amount())", + } ); /** * Zod schema for event payload validation. - * + * * Used by all event consumer methods to ensure consistent validation. - * + * * Validates: * - userId: non-empty string * - Exactly one of: debitAmount (number), debitTag (string), or debitExpr (PriceExpr) */ -export const EventPayloadSchema = z.object({ - userId: z.string().min(1, 'userId must be a non-empty string'), - debitAmount: z.number().positive('debitAmount must be a positive number').optional(), - debitTag: z.string().min(1, 'debitTag must be a non-empty string').optional(), +export const EventPayloadSchema = z + .object({ + userId: z.string().min(1, "userId must be a non-empty string"), + debitAmount: z + .number() + .positive("debitAmount must be a positive number") + .optional(), + debitTag: z + .string() + .min(1, "debitTag must be a non-empty string") + .optional(), debitExpr: PriceExprSchema.optional(), -}).refine( + }) + .refine( (data) => { - const defined = [ - data.debitAmount !== undefined, - data.debitTag !== undefined, - data.debitExpr !== undefined, - ].filter(Boolean).length; - return defined === 1; + const defined = [ + data.debitAmount !== undefined, + data.debitTag !== undefined, + data.debitExpr !== undefined, + ].filter(Boolean).length; + return defined === 1; }, - { message: 'Exactly one of debitAmount, debitTag, or debitExpr must be provided' } -); - + { + message: + "Exactly one of debitAmount, debitTag, or debitExpr must be provided", + } + ); + /** * Payload structure for event tracking. - * + * * Used by both sdkCallEventConsumer and middlewareEventConsumer. - * + * * @property userId - The user ID associated with this event * @property debitAmount - (Optional) Direct amount to debit in cents * @property debitTag - (Optional) Named price tag to look up amount from backend * @property debitExpr - (Optional) Pricing expression for complex calculations - * + * * Note: Exactly one of debitAmount, debitTag, or debitExpr must be provided. - * + * * @example * ```typescript * import { add, mul, tag } from '@scrawn/core'; - * + * * // Using direct amount * const payload1: EventPayload = { * userId: 'u123', * debitAmount: 500 // 500 cents = $5.00 * }; - * + * * // Using price tag * const payload2: EventPayload = { * userId: 'u123', * debitTag: 'PREMIUM_FEATURE' * }; - * + * * // Using pricing expression * const payload3: EventPayload = { * userId: 'u123', @@ -84,149 +98,151 @@ export const EventPayloadSchema = z.object({ * ``` */ export type EventPayload = z.infer; - -/** - * Generic request object type for middleware compatibility. - * Supports Express, Fastify, and other Node.js frameworks. - */ -export interface MiddlewareRequest { - /** HTTP method (GET, POST, etc.) */ - method?: string; - /** Request URL path */ - url?: string; - /** Request path (alternative to url) */ - path?: string; - /** Request body */ - body?: any; - /** Request headers */ - headers?: Record; - /** Request query parameters */ - query?: Record; - /** Request params (route parameters) */ - params?: Record; - /** Any additional custom properties */ - [key: string]: any; -} - -/** - * Generic response object type for middleware compatibility. - */ -export interface MiddlewareResponse { - /** Status code setter */ - status?: (code: number) => MiddlewareResponse; - /** JSON response sender */ - json?: (data: any) => void; - /** Send response */ - send?: (data: any) => void; - /** Any additional custom properties */ - [key: string]: any; -} - -/** - * Generic next function type for middleware compatibility. - */ -export type MiddlewareNext = (error?: any) => void; - -/** - * Extractor function that derives userId and debit info from a request. - * - * @param req - The incoming request object - * @returns An object containing userId and either debitAmount or debitTag, a Promise resolving to it, or null to skip tracking - * - * @example - * ```typescript - * // Using direct amount - * const extractor: PayloadExtractor = (req) => ({ - * userId: req.headers['x-user-id'] as string, - * debitAmount: 1 - * }); - * - * // Using price tag - * const extractor: PayloadExtractor = (req) => ({ - * userId: req.user.id, - * debitTag: 'STANDARD_API_CALL' - * }); - * - * // Dynamic tag based on route - * const extractor: PayloadExtractor = (req) => { - * if (req.path === '/health') return null; - * return { - * userId: req.user.id, - * debitTag: req.path.startsWith('/premium') ? 'PREMIUM_API' : 'STANDARD_API' - * }; - * }; - * ``` - */ -export type PayloadExtractor = ( - req: MiddlewareRequest -) => EventPayload | Promise | null | Promise; - -/** - * Configuration options for the Express middleware event consumer. - * - * @property extractor - Function to extract userId and debit info from request. Return null to skip tracking. - * @property whitelist - Optional array of endpoint patterns to track. Supports wildcards (* for single segment, ** for multiple segments). Takes precedence over blacklist. - * @property blacklist - Optional array of endpoint patterns to exclude from tracking. Same wildcard support as whitelist. Only applies to endpoints not in whitelist. - * - * @example - * ```typescript - * // Using direct amounts - * const config1: MiddlewareEventConfig = { - * extractor: (req) => ({ - * userId: req.user?.id, - * debitAmount: calculateCost(req) - * }), - * whitelist: ['/api/expensive-operation', '/api/premium-feature'], - * blacklist: ['/api/health'] - * }; - * - * // Using price tags - * const config2: MiddlewareEventConfig = { - * extractor: (req) => ({ - * userId: req.user?.id, - * debitTag: req.path.startsWith('/premium') ? 'PREMIUM_API' : 'STANDARD_API' - * }), - * whitelist: ['/api/**'], - * blacklist: ['**.tmp'] - * }; - * ``` - */ + +/** + * Generic request object type for middleware compatibility. + * Supports Express, Fastify, and other Node.js frameworks. + */ +export interface MiddlewareRequest { + /** HTTP method (GET, POST, etc.) */ + method?: string; + /** Request URL path */ + url?: string; + /** Request path (alternative to url) */ + path?: string; + /** Request body */ + body?: any; + /** Request headers */ + headers?: Record; + /** Request query parameters */ + query?: Record; + /** Request params (route parameters) */ + params?: Record; + /** Any additional custom properties */ + [key: string]: any; +} + +/** + * Generic response object type for middleware compatibility. + */ +export interface MiddlewareResponse { + /** Status code setter */ + status?: (code: number) => MiddlewareResponse; + /** JSON response sender */ + json?: (data: any) => void; + /** Send response */ + send?: (data: any) => void; + /** Any additional custom properties */ + [key: string]: any; +} + +/** + * Generic next function type for middleware compatibility. + */ +export type MiddlewareNext = (error?: any) => void; + +/** + * Extractor function that derives userId and debit info from a request. + * + * @param req - The incoming request object + * @returns An object containing userId and either debitAmount or debitTag, a Promise resolving to it, or null to skip tracking + * + * @example + * ```typescript + * // Using direct amount + * const extractor: PayloadExtractor = (req) => ({ + * userId: req.headers['x-user-id'] as string, + * debitAmount: 1 + * }); + * + * // Using price tag + * const extractor: PayloadExtractor = (req) => ({ + * userId: req.user.id, + * debitTag: 'STANDARD_API_CALL' + * }); + * + * // Dynamic tag based on route + * const extractor: PayloadExtractor = (req) => { + * if (req.path === '/health') return null; + * return { + * userId: req.user.id, + * debitTag: req.path.startsWith('/premium') ? 'PREMIUM_API' : 'STANDARD_API' + * }; + * }; + * ``` + */ +export type PayloadExtractor = ( + req: MiddlewareRequest +) => EventPayload | Promise | null | Promise; + +/** + * Configuration options for the Express middleware event consumer. + * + * @property extractor - Function to extract userId and debit info from request. Return null to skip tracking. + * @property whitelist - Optional array of endpoint patterns to track. Supports wildcards (* for single segment, ** for multiple segments). Takes precedence over blacklist. + * @property blacklist - Optional array of endpoint patterns to exclude from tracking. Same wildcard support as whitelist. Only applies to endpoints not in whitelist. + * + * @example + * ```typescript + * // Using direct amounts + * const config1: MiddlewareEventConfig = { + * extractor: (req) => ({ + * userId: req.user?.id, + * debitAmount: calculateCost(req) + * }), + * whitelist: ['/api/expensive-operation', '/api/premium-feature'], + * blacklist: ['/api/health'] + * }; + * + * // Using price tags + * const config2: MiddlewareEventConfig = { + * extractor: (req) => ({ + * userId: req.user?.id, + * debitTag: req.path.startsWith('/premium') ? 'PREMIUM_API' : 'STANDARD_API' + * }), + * whitelist: ['/api/**'], + * blacklist: ['**.tmp'] + * }; + * ``` + */ export interface MiddlewareEventConfig { - /** Function to extract event payload from request. Return null to skip tracking. */ - extractor: PayloadExtractor; - /** Optional patterns to track (exact match or wildcards: * for single segment, ** for multi-segment). Takes precedence over blacklist. */ - whitelist?: string[]; - /** Optional patterns to exclude (exact match or wildcards: * for single segment, ** for multi-segment). Only applies to endpoints not in whitelist. */ - blacklist?: string[]; + /** Function to extract event payload from request. Return null to skip tracking. */ + extractor: PayloadExtractor; + /** Optional patterns to track (exact match or wildcards: * for single segment, ** for multi-segment). Takes precedence over blacklist. */ + whitelist?: string[]; + /** Optional patterns to exclude (exact match or wildcards: * for single segment, ** for multi-segment). Only applies to endpoints not in whitelist. */ + blacklist?: string[]; } /** * Debit field schema for AI token usage. - * + * * Represents a direct amount, a named price tag, or a pricing expression for billing. * Exactly one of amount, tag, or expr must be provided. */ -const DebitFieldSchema = z.object({ - amount: z.number().nonnegative('amount must be non-negative').optional(), - tag: z.string().min(1, 'tag must be a non-empty string').optional(), +const DebitFieldSchema = z + .object({ + amount: z.number().nonnegative("amount must be non-negative").optional(), + tag: z.string().min(1, "tag must be a non-empty string").optional(), expr: PriceExprSchema.optional(), -}).refine( + }) + .refine( (data) => { - const defined = [ - data.amount !== undefined, - data.tag !== undefined, - data.expr !== undefined, - ].filter(Boolean).length; - return defined === 1; + const defined = [ + data.amount !== undefined, + data.tag !== undefined, + data.expr !== undefined, + ].filter(Boolean).length; + return defined === 1; }, - { message: 'Exactly one of amount, tag, or expr must be provided' } -); + { message: "Exactly one of amount, tag, or expr must be provided" } + ); /** * Zod schema for AI token usage payload validation. - * + * * Used by aiTokenStreamConsumer to validate each token usage event. - * + * * Validates: * - userId: non-empty string * - model: non-empty string (e.g., 'gpt-4', 'claude-3') @@ -236,31 +252,37 @@ const DebitFieldSchema = z.object({ * - outputDebit: exactly one of amount (number), tag (string), or expr (PriceExpr) */ export const AITokenUsagePayloadSchema = z.object({ - userId: z.string().min(1, 'userId must be a non-empty string'), - model: z.string().min(1, 'model must be a non-empty string'), - inputTokens: z.number().int('inputTokens must be an integer').nonnegative('inputTokens must be non-negative'), - outputTokens: z.number().int('outputTokens must be an integer').nonnegative('outputTokens must be non-negative'), - inputDebit: DebitFieldSchema, - outputDebit: DebitFieldSchema, + userId: z.string().min(1, "userId must be a non-empty string"), + model: z.string().min(1, "model must be a non-empty string"), + inputTokens: z + .number() + .int("inputTokens must be an integer") + .nonnegative("inputTokens must be non-negative"), + outputTokens: z + .number() + .int("outputTokens must be an integer") + .nonnegative("outputTokens must be non-negative"), + inputDebit: DebitFieldSchema, + outputDebit: DebitFieldSchema, }); /** * Payload structure for AI token usage tracking. - * + * * Used by aiTokenStreamConsumer to track AI model token consumption. * Each payload represents a single usage event (e.g., one chunk or one request). - * + * * @property userId - The user ID associated with this token usage * @property model - The AI model identifier (e.g., 'gpt-4', 'claude-3-opus') * @property inputTokens - Number of input/prompt tokens consumed * @property outputTokens - Number of output/completion tokens consumed * @property inputDebit - Billing info for input tokens (amount, tag, or expr) * @property outputDebit - Billing info for output tokens (amount, tag, or expr) - * + * * @example * ```typescript * import { mul, tag } from '@scrawn/core'; - * + * * // Using direct amounts * const payload1: AITokenUsagePayload = { * userId: 'u123', @@ -270,7 +292,7 @@ export const AITokenUsagePayloadSchema = z.object({ * inputDebit: { amount: 3 }, // 3 cents * outputDebit: { amount: 6 } // 6 cents * }; - * + * * // Using price tags * const payload2: AITokenUsagePayload = { * userId: 'u123', @@ -280,7 +302,7 @@ export const AITokenUsagePayloadSchema = z.object({ * inputDebit: { tag: 'CLAUDE_INPUT' }, * outputDebit: { tag: 'CLAUDE_OUTPUT' } * }; - * + * * // Using pricing expressions (e.g., per-token pricing) * const payload3: AITokenUsagePayload = { * userId: 'u123', diff --git a/packages/scrawn/src/gen/auth/v1/auth_connect.ts b/packages/scrawn/src/gen/auth/v1/auth_connect.ts index 067f11f..7a99b45 100644 --- a/packages/scrawn/src/gen/auth/v1/auth_connect.ts +++ b/packages/scrawn/src/gen/auth/v1/auth_connect.ts @@ -23,6 +23,5 @@ export const AuthService = { O: CreateAPIKeyResponse, kind: MethodKind.Unary, }, - } + }, } as const; - diff --git a/packages/scrawn/src/gen/auth/v1/auth_pb.ts b/packages/scrawn/src/gen/auth/v1/auth_pb.ts index 5b85186..fd3f020 100644 --- a/packages/scrawn/src/gen/auth/v1/auth_pb.ts +++ b/packages/scrawn/src/gen/auth/v1/auth_pb.ts @@ -3,7 +3,14 @@ /* eslint-disable */ // @ts-nocheck -import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import type { + BinaryReadOptions, + FieldList, + JsonReadOptions, + JsonValue, + PartialMessage, + PlainMessage, +} from "@bufbuild/protobuf"; import { Message, proto3, protoInt64 } from "@bufbuild/protobuf"; /** @@ -34,19 +41,31 @@ export class CreateAPIKeyRequest extends Message { { no: 2, name: "expiresIn", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): CreateAPIKeyRequest { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): CreateAPIKeyRequest { return new CreateAPIKeyRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): CreateAPIKeyRequest { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): CreateAPIKeyRequest { return new CreateAPIKeyRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): CreateAPIKeyRequest { + static fromJsonString( + jsonString: string, + options?: Partial + ): CreateAPIKeyRequest { return new CreateAPIKeyRequest().fromJsonString(jsonString, options); } - static equals(a: CreateAPIKeyRequest | PlainMessage | undefined, b: CreateAPIKeyRequest | PlainMessage | undefined): boolean { + static equals( + a: CreateAPIKeyRequest | PlainMessage | undefined, + b: CreateAPIKeyRequest | PlainMessage | undefined + ): boolean { return proto3.util.equals(CreateAPIKeyRequest, a, b); } } @@ -95,20 +114,31 @@ export class CreateAPIKeyResponse extends Message { { no: 5, name: "expiresAt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): CreateAPIKeyResponse { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): CreateAPIKeyResponse { return new CreateAPIKeyResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): CreateAPIKeyResponse { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): CreateAPIKeyResponse { return new CreateAPIKeyResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): CreateAPIKeyResponse { + static fromJsonString( + jsonString: string, + options?: Partial + ): CreateAPIKeyResponse { return new CreateAPIKeyResponse().fromJsonString(jsonString, options); } - static equals(a: CreateAPIKeyResponse | PlainMessage | undefined, b: CreateAPIKeyResponse | PlainMessage | undefined): boolean { + static equals( + a: CreateAPIKeyResponse | PlainMessage | undefined, + b: CreateAPIKeyResponse | PlainMessage | undefined + ): boolean { return proto3.util.equals(CreateAPIKeyResponse, a, b); } } - diff --git a/packages/scrawn/src/gen/event/v1/event_connect.ts b/packages/scrawn/src/gen/event/v1/event_connect.ts index 234ef19..fd5bb5b 100644 --- a/packages/scrawn/src/gen/event/v1/event_connect.ts +++ b/packages/scrawn/src/gen/event/v1/event_connect.ts @@ -3,7 +3,12 @@ /* eslint-disable */ // @ts-nocheck -import { RegisterEventRequest, RegisterEventResponse, StreamEventRequest, StreamEventResponse } from "./event_pb.js"; +import { + RegisterEventRequest, + RegisterEventResponse, + StreamEventRequest, + StreamEventResponse, +} from "./event_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -34,6 +39,5 @@ export const EventService = { O: StreamEventResponse, kind: MethodKind.ClientStreaming, }, - } + }, } as const; - diff --git a/packages/scrawn/src/gen/event/v1/event_pb.ts b/packages/scrawn/src/gen/event/v1/event_pb.ts index cf9f799..d058d17 100644 --- a/packages/scrawn/src/gen/event/v1/event_pb.ts +++ b/packages/scrawn/src/gen/event/v1/event_pb.ts @@ -3,7 +3,14 @@ /* eslint-disable */ // @ts-nocheck -import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import type { + BinaryReadOptions, + FieldList, + JsonReadOptions, + JsonValue, + PartialMessage, + PlainMessage, +} from "@bufbuild/protobuf"; import { Message, proto3 } from "@bufbuild/protobuf"; /** @@ -75,13 +82,15 @@ export class RegisterEventRequest extends Message { /** * @generated from oneof event.v1.RegisterEventRequest.data */ - data: { - /** - * @generated from field: event.v1.SDKCall sdkCall = 3; - */ - value: SDKCall; - case: "sdkCall"; - } | { case: undefined; value?: undefined } = { case: undefined }; + data: + | { + /** + * @generated from field: event.v1.SDKCall sdkCall = 3; + */ + value: SDKCall; + case: "sdkCall"; + } + | { case: undefined; value?: undefined } = { case: undefined }; constructor(data?: PartialMessage) { super(); @@ -96,19 +105,31 @@ export class RegisterEventRequest extends Message { { no: 3, name: "sdkCall", kind: "message", T: SDKCall, oneof: "data" }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): RegisterEventRequest { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): RegisterEventRequest { return new RegisterEventRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): RegisterEventRequest { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): RegisterEventRequest { return new RegisterEventRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): RegisterEventRequest { + static fromJsonString( + jsonString: string, + options?: Partial + ): RegisterEventRequest { return new RegisterEventRequest().fromJsonString(jsonString, options); } - static equals(a: RegisterEventRequest | PlainMessage | undefined, b: RegisterEventRequest | PlainMessage | undefined): boolean { + static equals( + a: RegisterEventRequest | PlainMessage | undefined, + b: RegisterEventRequest | PlainMessage | undefined + ): boolean { return proto3.util.equals(RegisterEventRequest, a, b); } } @@ -125,27 +146,31 @@ export class SDKCall extends Message { /** * @generated from oneof event.v1.SDKCall.debit */ - debit: { - /** - * @generated from field: float amount = 2; - */ - value: number; - case: "amount"; - } | { - /** - * @generated from field: string tag = 3; - */ - value: string; - case: "tag"; - } | { - /** - * Pricing expression (e.g., "add(mul(tag('PREMIUM'),3),250)") - * - * @generated from field: string expr = 4; - */ - value: string; - case: "expr"; - } | { case: undefined; value?: undefined } = { case: undefined }; + debit: + | { + /** + * @generated from field: float amount = 2; + */ + value: number; + case: "amount"; + } + | { + /** + * @generated from field: string tag = 3; + */ + value: string; + case: "tag"; + } + | { + /** + * Pricing expression (e.g., "add(mul(tag('PREMIUM'),3),250)") + * + * @generated from field: string expr = 4; + */ + value: string; + case: "expr"; + } + | { case: undefined; value?: undefined } = { case: undefined }; constructor(data?: PartialMessage) { super(); @@ -155,25 +180,60 @@ export class SDKCall extends Message { static readonly runtime: typeof proto3 = proto3; static readonly typeName = "event.v1.SDKCall"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "sdkCallType", kind: "enum", T: proto3.getEnumType(SDKCallType) }, - { no: 2, name: "amount", kind: "scalar", T: 2 /* ScalarType.FLOAT */, oneof: "debit" }, - { no: 3, name: "tag", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "debit" }, - { no: 4, name: "expr", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "debit" }, + { + no: 1, + name: "sdkCallType", + kind: "enum", + T: proto3.getEnumType(SDKCallType), + }, + { + no: 2, + name: "amount", + kind: "scalar", + T: 2 /* ScalarType.FLOAT */, + oneof: "debit", + }, + { + no: 3, + name: "tag", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + oneof: "debit", + }, + { + no: 4, + name: "expr", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + oneof: "debit", + }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): SDKCall { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): SDKCall { return new SDKCall().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): SDKCall { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): SDKCall { return new SDKCall().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): SDKCall { + static fromJsonString( + jsonString: string, + options?: Partial + ): SDKCall { return new SDKCall().fromJsonString(jsonString, options); } - static equals(a: SDKCall | PlainMessage | undefined, b: SDKCall | PlainMessage | undefined): boolean { + static equals( + a: SDKCall | PlainMessage | undefined, + b: SDKCall | PlainMessage | undefined + ): boolean { return proto3.util.equals(SDKCall, a, b); } } @@ -198,19 +258,31 @@ export class RegisterEventResponse extends Message { { no: 1, name: "random", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): RegisterEventResponse { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): RegisterEventResponse { return new RegisterEventResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): RegisterEventResponse { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): RegisterEventResponse { return new RegisterEventResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): RegisterEventResponse { + static fromJsonString( + jsonString: string, + options?: Partial + ): RegisterEventResponse { return new RegisterEventResponse().fromJsonString(jsonString, options); } - static equals(a: RegisterEventResponse | PlainMessage | undefined, b: RegisterEventResponse | PlainMessage | undefined): boolean { + static equals( + a: RegisterEventResponse | PlainMessage | undefined, + b: RegisterEventResponse | PlainMessage | undefined + ): boolean { return proto3.util.equals(RegisterEventResponse, a, b); } } @@ -232,19 +304,22 @@ export class StreamEventRequest extends Message { /** * @generated from oneof event.v1.StreamEventRequest.data */ - data: { - /** - * @generated from field: event.v1.SDKCall sdkCall = 3; - */ - value: SDKCall; - case: "sdkCall"; - } | { - /** - * @generated from field: event.v1.AITokenUsage aiTokenUsage = 4; - */ - value: AITokenUsage; - case: "aiTokenUsage"; - } | { case: undefined; value?: undefined } = { case: undefined }; + data: + | { + /** + * @generated from field: event.v1.SDKCall sdkCall = 3; + */ + value: SDKCall; + case: "sdkCall"; + } + | { + /** + * @generated from field: event.v1.AITokenUsage aiTokenUsage = 4; + */ + value: AITokenUsage; + case: "aiTokenUsage"; + } + | { case: undefined; value?: undefined } = { case: undefined }; constructor(data?: PartialMessage) { super(); @@ -257,22 +332,40 @@ export class StreamEventRequest extends Message { { no: 1, name: "type", kind: "enum", T: proto3.getEnumType(EventType) }, { no: 2, name: "userId", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 3, name: "sdkCall", kind: "message", T: SDKCall, oneof: "data" }, - { no: 4, name: "aiTokenUsage", kind: "message", T: AITokenUsage, oneof: "data" }, + { + no: 4, + name: "aiTokenUsage", + kind: "message", + T: AITokenUsage, + oneof: "data", + }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): StreamEventRequest { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): StreamEventRequest { return new StreamEventRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): StreamEventRequest { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): StreamEventRequest { return new StreamEventRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): StreamEventRequest { + static fromJsonString( + jsonString: string, + options?: Partial + ): StreamEventRequest { return new StreamEventRequest().fromJsonString(jsonString, options); } - static equals(a: StreamEventRequest | PlainMessage | undefined, b: StreamEventRequest | PlainMessage | undefined): boolean { + static equals( + a: StreamEventRequest | PlainMessage | undefined, + b: StreamEventRequest | PlainMessage | undefined + ): boolean { return proto3.util.equals(StreamEventRequest, a, b); } } @@ -299,52 +392,60 @@ export class AITokenUsage extends Message { /** * @generated from oneof event.v1.AITokenUsage.inputDebit */ - inputDebit: { - /** - * @generated from field: float inputAmount = 4; - */ - value: number; - case: "inputAmount"; - } | { - /** - * @generated from field: string inputTag = 5; - */ - value: string; - case: "inputTag"; - } | { - /** - * Pricing expression for input tokens - * - * @generated from field: string inputExpr = 8; - */ - value: string; - case: "inputExpr"; - } | { case: undefined; value?: undefined } = { case: undefined }; + inputDebit: + | { + /** + * @generated from field: float inputAmount = 4; + */ + value: number; + case: "inputAmount"; + } + | { + /** + * @generated from field: string inputTag = 5; + */ + value: string; + case: "inputTag"; + } + | { + /** + * Pricing expression for input tokens + * + * @generated from field: string inputExpr = 8; + */ + value: string; + case: "inputExpr"; + } + | { case: undefined; value?: undefined } = { case: undefined }; /** * @generated from oneof event.v1.AITokenUsage.outputDebit */ - outputDebit: { - /** - * @generated from field: float outputAmount = 6; - */ - value: number; - case: "outputAmount"; - } | { - /** - * @generated from field: string outputTag = 7; - */ - value: string; - case: "outputTag"; - } | { - /** - * Pricing expression for output tokens - * - * @generated from field: string outputExpr = 9; - */ - value: string; - case: "outputExpr"; - } | { case: undefined; value?: undefined } = { case: undefined }; + outputDebit: + | { + /** + * @generated from field: float outputAmount = 6; + */ + value: number; + case: "outputAmount"; + } + | { + /** + * @generated from field: string outputTag = 7; + */ + value: string; + case: "outputTag"; + } + | { + /** + * Pricing expression for output tokens + * + * @generated from field: string outputExpr = 9; + */ + value: string; + case: "outputExpr"; + } + | { case: undefined; value?: undefined } = { case: undefined }; constructor(data?: PartialMessage) { super(); @@ -356,28 +457,81 @@ export class AITokenUsage extends Message { static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 2, name: "inputTokens", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, - { no: 3, name: "outputTokens", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, - { no: 4, name: "inputAmount", kind: "scalar", T: 2 /* ScalarType.FLOAT */, oneof: "inputDebit" }, - { no: 5, name: "inputTag", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "inputDebit" }, - { no: 8, name: "inputExpr", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "inputDebit" }, - { no: 6, name: "outputAmount", kind: "scalar", T: 2 /* ScalarType.FLOAT */, oneof: "outputDebit" }, - { no: 7, name: "outputTag", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "outputDebit" }, - { no: 9, name: "outputExpr", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "outputDebit" }, + { + no: 3, + name: "outputTokens", + kind: "scalar", + T: 5 /* ScalarType.INT32 */, + }, + { + no: 4, + name: "inputAmount", + kind: "scalar", + T: 2 /* ScalarType.FLOAT */, + oneof: "inputDebit", + }, + { + no: 5, + name: "inputTag", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + oneof: "inputDebit", + }, + { + no: 8, + name: "inputExpr", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + oneof: "inputDebit", + }, + { + no: 6, + name: "outputAmount", + kind: "scalar", + T: 2 /* ScalarType.FLOAT */, + oneof: "outputDebit", + }, + { + no: 7, + name: "outputTag", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + oneof: "outputDebit", + }, + { + no: 9, + name: "outputExpr", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + oneof: "outputDebit", + }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): AITokenUsage { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): AITokenUsage { return new AITokenUsage().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): AITokenUsage { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): AITokenUsage { return new AITokenUsage().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): AITokenUsage { + static fromJsonString( + jsonString: string, + options?: Partial + ): AITokenUsage { return new AITokenUsage().fromJsonString(jsonString, options); } - static equals(a: AITokenUsage | PlainMessage | undefined, b: AITokenUsage | PlainMessage | undefined): boolean { + static equals( + a: AITokenUsage | PlainMessage | undefined, + b: AITokenUsage | PlainMessage | undefined + ): boolean { return proto3.util.equals(AITokenUsage, a, b); } } @@ -404,24 +558,40 @@ export class StreamEventResponse extends Message { static readonly runtime: typeof proto3 = proto3; static readonly typeName = "event.v1.StreamEventResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "eventsProcessed", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { + no: 1, + name: "eventsProcessed", + kind: "scalar", + T: 5 /* ScalarType.INT32 */, + }, { no: 2, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): StreamEventResponse { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): StreamEventResponse { return new StreamEventResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): StreamEventResponse { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): StreamEventResponse { return new StreamEventResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): StreamEventResponse { + static fromJsonString( + jsonString: string, + options?: Partial + ): StreamEventResponse { return new StreamEventResponse().fromJsonString(jsonString, options); } - static equals(a: StreamEventResponse | PlainMessage | undefined, b: StreamEventResponse | PlainMessage | undefined): boolean { + static equals( + a: StreamEventResponse | PlainMessage | undefined, + b: StreamEventResponse | PlainMessage | undefined + ): boolean { return proto3.util.equals(StreamEventResponse, a, b); } } - diff --git a/packages/scrawn/src/gen/payment/v1/payment_connect.ts b/packages/scrawn/src/gen/payment/v1/payment_connect.ts index 9ce4621..b67653b 100644 --- a/packages/scrawn/src/gen/payment/v1/payment_connect.ts +++ b/packages/scrawn/src/gen/payment/v1/payment_connect.ts @@ -3,7 +3,10 @@ /* eslint-disable */ // @ts-nocheck -import { CreateCheckoutLinkRequest, CreateCheckoutLinkResponse } from "./payment_pb.js"; +import { + CreateCheckoutLinkRequest, + CreateCheckoutLinkResponse, +} from "./payment_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -23,6 +26,5 @@ export const PaymentService = { O: CreateCheckoutLinkResponse, kind: MethodKind.Unary, }, - } + }, } as const; - diff --git a/packages/scrawn/src/gen/payment/v1/payment_pb.ts b/packages/scrawn/src/gen/payment/v1/payment_pb.ts index 69abd20..ea5bbbf 100644 --- a/packages/scrawn/src/gen/payment/v1/payment_pb.ts +++ b/packages/scrawn/src/gen/payment/v1/payment_pb.ts @@ -3,7 +3,14 @@ /* eslint-disable */ // @ts-nocheck -import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import type { + BinaryReadOptions, + FieldList, + JsonReadOptions, + JsonValue, + PartialMessage, + PlainMessage, +} from "@bufbuild/protobuf"; import { Message, proto3 } from "@bufbuild/protobuf"; /** @@ -26,19 +33,37 @@ export class CreateCheckoutLinkRequest extends Message): CreateCheckoutLinkRequest { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): CreateCheckoutLinkRequest { return new CreateCheckoutLinkRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): CreateCheckoutLinkRequest { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): CreateCheckoutLinkRequest { return new CreateCheckoutLinkRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): CreateCheckoutLinkRequest { + static fromJsonString( + jsonString: string, + options?: Partial + ): CreateCheckoutLinkRequest { return new CreateCheckoutLinkRequest().fromJsonString(jsonString, options); } - static equals(a: CreateCheckoutLinkRequest | PlainMessage | undefined, b: CreateCheckoutLinkRequest | PlainMessage | undefined): boolean { + static equals( + a: + | CreateCheckoutLinkRequest + | PlainMessage + | undefined, + b: + | CreateCheckoutLinkRequest + | PlainMessage + | undefined + ): boolean { return proto3.util.equals(CreateCheckoutLinkRequest, a, b); } } @@ -60,23 +85,45 @@ export class CreateCheckoutLinkResponse extends Message [ - { no: 1, name: "checkoutLink", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { + no: 1, + name: "checkoutLink", + kind: "scalar", + T: 9 /* ScalarType.STRING */, + }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): CreateCheckoutLinkResponse { + static fromBinary( + bytes: Uint8Array, + options?: Partial + ): CreateCheckoutLinkResponse { return new CreateCheckoutLinkResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): CreateCheckoutLinkResponse { + static fromJson( + jsonValue: JsonValue, + options?: Partial + ): CreateCheckoutLinkResponse { return new CreateCheckoutLinkResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): CreateCheckoutLinkResponse { + static fromJsonString( + jsonString: string, + options?: Partial + ): CreateCheckoutLinkResponse { return new CreateCheckoutLinkResponse().fromJsonString(jsonString, options); } - static equals(a: CreateCheckoutLinkResponse | PlainMessage | undefined, b: CreateCheckoutLinkResponse | PlainMessage | undefined): boolean { + static equals( + a: + | CreateCheckoutLinkResponse + | PlainMessage + | undefined, + b: + | CreateCheckoutLinkResponse + | PlainMessage + | undefined + ): boolean { return proto3.util.equals(CreateCheckoutLinkResponse, a, b); } } - diff --git a/packages/scrawn/src/index.ts b/packages/scrawn/src/index.ts index fd52410..f8b0618 100644 --- a/packages/scrawn/src/index.ts +++ b/packages/scrawn/src/index.ts @@ -1,24 +1,24 @@ -export * from './core/scrawn.js'; -export * from './core/types/event.js'; -export * from './core/types/auth.js'; - -// Export error classes for user error handling -export * from './core/errors/index.js'; - -// Export gRPC client abstraction layer -export * from './core/grpc/index.js'; - -// Export pricing DSL for building complex billing expressions -export * from './core/pricing/index.js'; - -// Export utilities -export { matchPath } from './utils/pathMatcher.js'; - -// Export generated types for advanced usage -export * from './gen/event/v1/event_connect.js'; -export * from './gen/event/v1/event_pb.js'; -export * from './gen/auth/v1/auth_connect.js'; -export * from './gen/auth/v1/auth_pb.js'; - -// Export central configuration -export { ScrawnConfig } from './config.js'; \ No newline at end of file +export * from "./core/scrawn.js"; +export * from "./core/types/event.js"; +export * from "./core/types/auth.js"; + +// Export error classes for user error handling +export * from "./core/errors/index.js"; + +// Export gRPC client abstraction layer +export * from "./core/grpc/index.js"; + +// Export pricing DSL for building complex billing expressions +export * from "./core/pricing/index.js"; + +// Export utilities +export { matchPath } from "./utils/pathMatcher.js"; + +// Export generated types for advanced usage +export * from "./gen/event/v1/event_connect.js"; +export * from "./gen/event/v1/event_pb.js"; +export * from "./gen/auth/v1/auth_connect.js"; +export * from "./gen/auth/v1/auth_pb.js"; + +// Export central configuration +export { ScrawnConfig } from "./config.js"; diff --git a/packages/scrawn/src/utils/forkAsyncIterable.ts b/packages/scrawn/src/utils/forkAsyncIterable.ts index 604f816..8231870 100644 --- a/packages/scrawn/src/utils/forkAsyncIterable.ts +++ b/packages/scrawn/src/utils/forkAsyncIterable.ts @@ -1,12 +1,12 @@ /** * Forks an async iterable into two independent async iterables. - * + * * Both returned iterables will receive the same items from the source. * Items are buffered internally so both consumers can read at their own pace. - * + * * @param source - The source async iterable to fork * @returns A tuple of two async iterables that both yield the same items - * + * * @internal */ export function forkAsyncIterable( @@ -14,10 +14,10 @@ export function forkAsyncIterable( ): [AsyncIterable, AsyncIterable] { const buffer1: T[] = []; const buffer2: T[] = []; - + let resolve1: (() => void) | null = null; let resolve2: (() => void) | null = null; - + let done = false; let started = false; @@ -25,12 +25,12 @@ export function forkAsyncIterable( async function startConsuming() { if (started) return; started = true; - + try { for await (const item of source) { buffer1.push(item); buffer2.push(item); - + // Wake up any waiting consumers if (resolve1) { resolve1(); @@ -61,7 +61,7 @@ export function forkAsyncIterable( ): AsyncGenerator { // Kick off consuming (idempotent) startConsuming(); - + while (true) { if (buffer.length > 0) { yield buffer.shift()!; @@ -76,8 +76,12 @@ export function forkAsyncIterable( } } - const iter1 = createIterator(buffer1, (r) => { resolve1 = r; }); - const iter2 = createIterator(buffer2, (r) => { resolve2 = r; }); + const iter1 = createIterator(buffer1, (r) => { + resolve1 = r; + }); + const iter2 = createIterator(buffer2, (r) => { + resolve2 = r; + }); return [iter1, iter2]; } diff --git a/packages/scrawn/src/utils/logger.ts b/packages/scrawn/src/utils/logger.ts index 39859f8..bf329db 100644 --- a/packages/scrawn/src/utils/logger.ts +++ b/packages/scrawn/src/utils/logger.ts @@ -1,74 +1,78 @@ -import fs from 'fs'; -import path from 'path'; -import chalk from 'chalk'; -import pino from 'pino'; -import { ScrawnConfig } from '../config.js'; - -type LogLevel = 'info' | 'warn' | 'error' | 'debug'; - -// ensure log file directory exists -const logFilePath = path.resolve(process.cwd(), 'scrawn.log'); -fs.mkdirSync(path.dirname(logFilePath), { recursive: true }); - -// create pino instance writing to file -const baseLogger = pino( - { - name: 'scrawn', - level: ScrawnConfig.logging.enableDebug ? 'debug' : 'info', - timestamp: pino.stdTimeFunctions.isoTime, - }, - pino.destination(logFilePath) -); - -export class ScrawnLogger { - constructor(private context: string = 'Scrawn') {} - - private log(level: LogLevel, message: string, ...args: any[]) { - const timestamp = `[${new Date().toLocaleTimeString('en-IN', { timeZone: 'Asia/Kolkata' })}]`; - const prefix = `[${this.context}]`; - - let colorizedPrefix: string; - switch (level) { - case 'info': - colorizedPrefix = chalk.cyan(`${timestamp} ${prefix}`); - baseLogger.info({ context: this.context, ...args[0] }, message); - break; - case 'warn': - colorizedPrefix = chalk.yellow(`${timestamp} ${prefix}`); - baseLogger.warn({ context: this.context, ...args[0] }, message); - break; - case 'error': - colorizedPrefix = chalk.redBright(`${timestamp} ${prefix}`); - baseLogger.error({ context: this.context, ...args[0] }, message); - break; - case 'debug': - colorizedPrefix = chalk.magenta(`${timestamp} ${prefix}`); - baseLogger.debug({ context: this.context, ...args[0] }, message); - break; - default: - colorizedPrefix = `${timestamp} ${prefix}`; - } - - // skip debug unless enabled - if (level === 'debug' && !process.env.SCRAWN_DEBUG) return; - - // print to console with color - console.log(`${colorizedPrefix} ${message}`); - } - - info(msg: string, data?: any) { - this.log('info', msg, data); - } - - warn(msg: string, data?: any) { - this.log('warn', msg, data); - } - - error(msg: string, err?: any) { - this.log('error', msg, err instanceof Error ? { err: err.message, stack: err.stack } : err); - } - - debug(msg: string, data?: any) { - this.log('debug', msg, data); - } -} +import fs from "fs"; +import path from "path"; +import chalk from "chalk"; +import pino from "pino"; +import { ScrawnConfig } from "../config.js"; + +type LogLevel = "info" | "warn" | "error" | "debug"; + +// ensure log file directory exists +const logFilePath = path.resolve(process.cwd(), "scrawn.log"); +fs.mkdirSync(path.dirname(logFilePath), { recursive: true }); + +// create pino instance writing to file +const baseLogger = pino( + { + name: "scrawn", + level: ScrawnConfig.logging.enableDebug ? "debug" : "info", + timestamp: pino.stdTimeFunctions.isoTime, + }, + pino.destination(logFilePath) +); + +export class ScrawnLogger { + constructor(private context: string = "Scrawn") {} + + private log(level: LogLevel, message: string, ...args: any[]) { + const timestamp = `[${new Date().toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata" })}]`; + const prefix = `[${this.context}]`; + + let colorizedPrefix: string; + switch (level) { + case "info": + colorizedPrefix = chalk.cyan(`${timestamp} ${prefix}`); + baseLogger.info({ context: this.context, ...args[0] }, message); + break; + case "warn": + colorizedPrefix = chalk.yellow(`${timestamp} ${prefix}`); + baseLogger.warn({ context: this.context, ...args[0] }, message); + break; + case "error": + colorizedPrefix = chalk.redBright(`${timestamp} ${prefix}`); + baseLogger.error({ context: this.context, ...args[0] }, message); + break; + case "debug": + colorizedPrefix = chalk.magenta(`${timestamp} ${prefix}`); + baseLogger.debug({ context: this.context, ...args[0] }, message); + break; + default: + colorizedPrefix = `${timestamp} ${prefix}`; + } + + // skip debug unless enabled + if (level === "debug" && !process.env.SCRAWN_DEBUG) return; + + // print to console with color + console.log(`${colorizedPrefix} ${message}`); + } + + info(msg: string, data?: any) { + this.log("info", msg, data); + } + + warn(msg: string, data?: any) { + this.log("warn", msg, data); + } + + error(msg: string, err?: any) { + this.log( + "error", + msg, + err instanceof Error ? { err: err.message, stack: err.stack } : err + ); + } + + debug(msg: string, data?: any) { + this.log("debug", msg, data); + } +} diff --git a/packages/scrawn/src/utils/pathMatcher.ts b/packages/scrawn/src/utils/pathMatcher.ts index f2f75e9..17740ac 100644 --- a/packages/scrawn/src/utils/pathMatcher.ts +++ b/packages/scrawn/src/utils/pathMatcher.ts @@ -1,47 +1,47 @@ -/** - * Match a path against a pattern with wildcard support. - * - * Supports: - * - Exact match: /api/users - * - Single segment wildcard (*): /api/star matches /api/users but not /api/users/123 - * - Multi-segment wildcard (**): /api/starstar matches /api/users, /api/users/123, etc. - * - Mixed patterns: /api/star/profile, /api/starstar/data, starstar.php, etc. - * - * @param path - The request path to match - * @param pattern - The pattern to match against - * @returns true if the path matches the pattern - * - * @example - * ```typescript - * matchPath('/api/users', '/api/users') // true - exact match - * matchPath('/api/users', '/api/*') // true - single segment wildcard - * matchPath('/api/users/123', '/api/*') // false - too many segments - * matchPath('/api/users/123', '/api/**') // true - multi-segment wildcard - * matchPath('/api/v1/users/data', '/api/** /data') // true - mixed pattern - * matchPath('/index.php', '**.php') // true - file extension match - * ``` - */ -export function matchPath(path: string, pattern: string): boolean { - // Exact match - if (path === pattern) return true; - - // No wildcards, no match - if (!pattern.includes('*')) return false; - - // Convert pattern to regex - // Escape special regex characters except * and / - let regexPattern = pattern - .replace(/[.+?^${}()|[\]\\]/g, '\\$&') - // Replace ** with a placeholder to handle it separately - .replace(/\*\*/g, '___DOUBLE_STAR___') - // Replace * with single segment match (anything except /) - .replace(/\*/g, '[^/]+') - // Replace ** placeholder with multi-segment match (anything including /) - .replace(/___DOUBLE_STAR___/g, '.*'); - - // Ensure we match the full path - regexPattern = `^${regexPattern}$`; - - const regex = new RegExp(regexPattern); - return regex.test(path); -} +/** + * Match a path against a pattern with wildcard support. + * + * Supports: + * - Exact match: /api/users + * - Single segment wildcard (*): /api/star matches /api/users but not /api/users/123 + * - Multi-segment wildcard (**): /api/starstar matches /api/users, /api/users/123, etc. + * - Mixed patterns: /api/star/profile, /api/starstar/data, starstar.php, etc. + * + * @param path - The request path to match + * @param pattern - The pattern to match against + * @returns true if the path matches the pattern + * + * @example + * ```typescript + * matchPath('/api/users', '/api/users') // true - exact match + * matchPath('/api/users', '/api/*') // true - single segment wildcard + * matchPath('/api/users/123', '/api/*') // false - too many segments + * matchPath('/api/users/123', '/api/**') // true - multi-segment wildcard + * matchPath('/api/v1/users/data', '/api/** /data') // true - mixed pattern + * matchPath('/index.php', '**.php') // true - file extension match + * ``` + */ +export function matchPath(path: string, pattern: string): boolean { + // Exact match + if (path === pattern) return true; + + // No wildcards, no match + if (!pattern.includes("*")) return false; + + // Convert pattern to regex + // Escape special regex characters except * and / + let regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + // Replace ** with a placeholder to handle it separately + .replace(/\*\*/g, "___DOUBLE_STAR___") + // Replace * with single segment match (anything except /) + .replace(/\*/g, "[^/]+") + // Replace ** placeholder with multi-segment match (anything including /) + .replace(/___DOUBLE_STAR___/g, ".*"); + + // Ensure we match the full path + regexPattern = `^${regexPattern}$`; + + const regex = new RegExp(regexPattern); + return regex.test(path); +} diff --git a/packages/scrawn/tests/mocks/mockTransport.ts b/packages/scrawn/tests/mocks/mockTransport.ts index 100209a..03e15dd 100644 --- a/packages/scrawn/tests/mocks/mockTransport.ts +++ b/packages/scrawn/tests/mocks/mockTransport.ts @@ -15,7 +15,14 @@ export function createMockTransport(handlers: { unary: UnaryHandler; }): Transport { return { - async unary(service, method, _signal, _timeoutMs, header, input): Promise { + async unary( + service, + method, + _signal, + _timeoutMs, + header, + input + ): Promise { const message = await handlers.unary({ service, method, diff --git a/packages/scrawn/tests/unit/auth/apiKeyAuth.test.ts b/packages/scrawn/tests/unit/auth/apiKeyAuth.test.ts index 5d85b27..2a7477f 100644 --- a/packages/scrawn/tests/unit/auth/apiKeyAuth.test.ts +++ b/packages/scrawn/tests/unit/auth/apiKeyAuth.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { ApiKeyAuth, isValidApiKey, validateApiKey } from "../../../src/core/auth/apiKeyAuth.js"; +import { + ApiKeyAuth, + isValidApiKey, + validateApiKey, +} from "../../../src/core/auth/apiKeyAuth.js"; import { ScrawnValidationError } from "../../../src/core/errors/index.js"; const validKey = "scrn_1234567890abcdef1234567890abcdef"; diff --git a/packages/scrawn/tests/unit/pricing/pricing.test.ts b/packages/scrawn/tests/unit/pricing/pricing.test.ts index c0a4cba..29182e4 100644 --- a/packages/scrawn/tests/unit/pricing/pricing.test.ts +++ b/packages/scrawn/tests/unit/pricing/pricing.test.ts @@ -22,8 +22,14 @@ describe("Pricing DSL Builders", () => { }); it("accepts underscores and hyphens", () => { - expect(tag("PREMIUM_CALL")).toEqual({ kind: "tag", name: "PREMIUM_CALL" }); - expect(tag("premium-call")).toEqual({ kind: "tag", name: "premium-call" }); + expect(tag("PREMIUM_CALL")).toEqual({ + kind: "tag", + name: "PREMIUM_CALL", + }); + expect(tag("premium-call")).toEqual({ + kind: "tag", + name: "premium-call", + }); expect(tag("_private")).toEqual({ kind: "tag", name: "_private" }); }); @@ -34,7 +40,9 @@ describe("Pricing DSL Builders", () => { it("throws on whitespace-only tag name", () => { expect(() => tag(" ")).toThrow(PricingExpressionError); - expect(() => tag(" ")).toThrow("Tag name cannot have leading or trailing whitespace"); + expect(() => tag(" ")).toThrow( + "Tag name cannot have leading or trailing whitespace" + ); }); it("throws on tag name with leading/trailing whitespace", () => { @@ -145,7 +153,7 @@ describe("Pricing DSL Builders", () => { expect(expr.kind).toBe("op"); expect(expr.op).toBe("ADD"); expect(expr.args).toHaveLength(3); - + const mulExpr = expr.args[0]; expect(mulExpr.kind).toBe("op"); if (mulExpr.kind === "op") { @@ -168,8 +176,8 @@ describe("Pricing DSL Serialization", () => { }); it("serializes tag expressions", () => { - expect(serializeExpr(tag("PREMIUM"))).toBe("tag('PREMIUM')"); - expect(serializeExpr(tag("API_CALL"))).toBe("tag('API_CALL')"); + expect(serializeExpr(tag("PREMIUM"))).toBe("tag(PREMIUM)"); + expect(serializeExpr(tag("API_CALL"))).toBe("tag(API_CALL)"); }); it("serializes simple operations", () => { @@ -180,20 +188,20 @@ describe("Pricing DSL Serialization", () => { }); it("serializes mixed operations", () => { - expect(serializeExpr(add(100, tag("FEE")))).toBe("add(100,tag('FEE'))"); + expect(serializeExpr(add(100, tag("FEE")))).toBe("add(100,tag(FEE))"); }); it("serializes nested operations", () => { const expr = add(mul(tag("PREMIUM_CALL"), 3), tag("EXTRA_FEE"), 250); expect(serializeExpr(expr)).toBe( - "add(mul(tag('PREMIUM_CALL'),3),tag('EXTRA_FEE'),250)" + "add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250)" ); }); it("serializes complex expressions", () => { const expr = div(add(mul(tag("INPUT"), 2), mul(tag("OUTPUT"), 3)), 100); expect(serializeExpr(expr)).toBe( - "div(add(mul(tag('INPUT'),2),mul(tag('OUTPUT'),3)),100)" + "div(add(mul(tag(INPUT),2),mul(tag(OUTPUT),3)),100)" ); }); }); @@ -201,7 +209,7 @@ describe("Pricing DSL Serialization", () => { describe("prettyPrintExpr()", () => { it("pretty prints simple expressions", () => { expect(prettyPrintExpr(amount(100))).toBe("100"); - expect(prettyPrintExpr(tag("PREMIUM"))).toBe("tag('PREMIUM')"); + expect(prettyPrintExpr(tag("PREMIUM"))).toBe("tag(PREMIUM)"); }); it("pretty prints operations with indentation", () => { @@ -218,18 +226,26 @@ describe("Pricing DSL Validation", () => { describe("validateExpr()", () => { it("validates amount expressions", () => { expect(() => validateExpr(amount(100))).not.toThrow(); - expect(() => validateExpr({ kind: "amount", value: 2.5 } as PriceExpr)).toThrow(); + expect(() => + validateExpr({ kind: "amount", value: 2.5 } as PriceExpr) + ).toThrow(); }); it("validates tag expressions", () => { expect(() => validateExpr(tag("VALID"))).not.toThrow(); - expect(() => validateExpr({ kind: "tag", name: "" } as PriceExpr)).toThrow(); + expect(() => + validateExpr({ kind: "tag", name: "" } as PriceExpr) + ).toThrow(); }); it("validates operation expressions", () => { expect(() => validateExpr(add(100, 200))).not.toThrow(); expect(() => - validateExpr({ kind: "op", op: "ADD", args: [amount(100)] } as PriceExpr) + validateExpr({ + kind: "op", + op: "ADD", + args: [amount(100)], + } as PriceExpr) ).toThrow(); }); }); @@ -242,7 +258,9 @@ describe("Pricing DSL Validation", () => { }); it("returns false for invalid expressions", () => { - expect(isValidExpr({ kind: "amount", value: 2.5 } as PriceExpr)).toBe(false); + expect(isValidExpr({ kind: "amount", value: 2.5 } as PriceExpr)).toBe( + false + ); expect(isValidExpr({ kind: "tag", name: "" } as PriceExpr)).toBe(false); }); }); @@ -253,7 +271,7 @@ describe("Integration Examples", () => { // (PREMIUM_CALL * 3) + EXTRA_FEE + 250 cents const expr = add(mul(tag("PREMIUM_CALL"), 3), tag("EXTRA_FEE"), 250); const serialized = serializeExpr(expr); - expect(serialized).toBe("add(mul(tag('PREMIUM_CALL'),3),tag('EXTRA_FEE'),250)"); + expect(serialized).toBe("add(mul(tag(PREMIUM_CALL),3),tag(EXTRA_FEE),250)"); }); it("handles per-token pricing", () => { @@ -264,16 +282,19 @@ describe("Integration Examples", () => { ); const serialized = serializeExpr(expr); expect(serialized).toBe( - "add(mul(tag('INPUT_TOKENS'),tag('INPUT_RATE')),mul(tag('OUTPUT_TOKENS'),tag('OUTPUT_RATE')))" + "add(mul(tag(INPUT_TOKENS),tag(INPUT_RATE)),mul(tag(OUTPUT_TOKENS),tag(OUTPUT_RATE)))" ); }); it("handles discount calculation", () => { // SUBTOTAL - (SUBTOTAL * DISCOUNT_PERCENT / 100) - const expr = sub(tag("SUBTOTAL"), div(mul(tag("SUBTOTAL"), tag("DISCOUNT_PERCENT")), 100)); + const expr = sub( + tag("SUBTOTAL"), + div(mul(tag("SUBTOTAL"), tag("DISCOUNT_PERCENT")), 100) + ); const serialized = serializeExpr(expr); expect(serialized).toBe( - "sub(tag('SUBTOTAL'),div(mul(tag('SUBTOTAL'),tag('DISCOUNT_PERCENT')),100))" + "sub(tag(SUBTOTAL),div(mul(tag(SUBTOTAL),tag(DISCOUNT_PERCENT)),100))" ); }); }); diff --git a/packages/scrawn/tests/unit/scrawn/middleware.test.ts b/packages/scrawn/tests/unit/scrawn/middleware.test.ts index 11ea8f6..7dd5f91 100644 --- a/packages/scrawn/tests/unit/scrawn/middleware.test.ts +++ b/packages/scrawn/tests/unit/scrawn/middleware.test.ts @@ -30,7 +30,10 @@ describe("middlewareEventConsumer", () => { }); it("tracks events for matching paths", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); const middleware = scrawn.middlewareEventConsumer({ extractor: () => ({ userId: "user_1", debitAmount: 2 }), whitelist: ["/api/**"], @@ -45,7 +48,10 @@ describe("middlewareEventConsumer", () => { }); it("skips events for non-whitelisted paths", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); const middleware = scrawn.middlewareEventConsumer({ extractor: () => ({ userId: "user_1", debitAmount: 2 }), whitelist: ["/billing/**"], @@ -60,7 +66,10 @@ describe("middlewareEventConsumer", () => { }); it("skips events when extractor returns null", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); const middleware = scrawn.middlewareEventConsumer({ extractor: () => null, }); diff --git a/packages/scrawn/tests/unit/scrawn/scrawn.test.ts b/packages/scrawn/tests/unit/scrawn/scrawn.test.ts index 5a89d82..0b889f1 100644 --- a/packages/scrawn/tests/unit/scrawn/scrawn.test.ts +++ b/packages/scrawn/tests/unit/scrawn/scrawn.test.ts @@ -9,13 +9,19 @@ import { SDKCallType, } from "../../../src/gen/event/v1/event_pb.js"; import { CreateCheckoutLinkResponse } from "../../../src/gen/payment/v1/payment_pb.js"; -import { ScrawnConfigError, ScrawnValidationError } from "../../../src/core/errors/index.js"; +import { + ScrawnConfigError, + ScrawnValidationError, +} from "../../../src/core/errors/index.js"; const validKey = "scrn_1234567890abcdef1234567890abcdef"; const transport = createMockTransport({ unary: ({ service, method, input, headers }) => { - if (service.typeName === EventService.typeName && method.name === "RegisterEvent") { + if ( + service.typeName === EventService.typeName && + method.name === "RegisterEvent" + ) { expect(headers).toEqual({ Authorization: `Bearer ${validKey}` }); expect(input).toMatchObject({ type: 1, @@ -32,10 +38,15 @@ const transport = createMockTransport({ return new RegisterEventResponse({ random: "ok" }); } - if (service.typeName === PaymentService.typeName && method.name === "CreateCheckoutLink") { + if ( + service.typeName === PaymentService.typeName && + method.name === "CreateCheckoutLink" + ) { expect(headers).toEqual({ Authorization: `Bearer ${validKey}` }); expect(input).toEqual({ userId: "user_1" }); - return new CreateCheckoutLinkResponse({ checkoutLink: "https://checkout.example" }); + return new CreateCheckoutLinkResponse({ + checkoutLink: "https://checkout.example", + }); } throw new Error("Unexpected call"); @@ -52,12 +63,18 @@ describe("Scrawn", () => { }); it("tracks SDK call events", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); await scrawn.sdkCallEventConsumer({ userId: "user_1", debitAmount: 5 }); }); it("rejects invalid event payloads", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); await expect( scrawn.sdkCallEventConsumer({ userId: "", debitAmount: 5 }) @@ -65,18 +82,26 @@ describe("Scrawn", () => { }); it("collects payment links", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); const link = await scrawn.collectPayment("user_1"); expect(link).toBe("https://checkout.example"); }); it("validates constructor config", () => { - expect(() => new Scrawn({ apiKey: "", baseURL: "" })).toThrow(ScrawnConfigError); + expect(() => new Scrawn({ apiKey: "", baseURL: "" })).toThrow( + ScrawnConfigError + ); }); it("validates collectPayment input", async () => { - const scrawn = new Scrawn({ apiKey: validKey, baseURL: "https://api.example" }); + const scrawn = new Scrawn({ + apiKey: validKey, + baseURL: "https://api.example", + }); await expect(scrawn.collectPayment("")).rejects.toBeInstanceOf( ScrawnValidationError diff --git a/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts b/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts index 5c33114..8fd1802 100644 --- a/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts +++ b/packages/scrawn/tests/unit/types/aiTokenUsagePayload.test.ts @@ -49,7 +49,9 @@ describe("AITokenUsagePayloadSchema", () => { model: "gpt-4", inputTokens: 100, outputTokens: 50, - inputDebit: { expr: add(mul(tag("BASE_RATE"), 100), tag("PREMIUM_FEE")) }, + inputDebit: { + expr: add(mul(tag("BASE_RATE"), 100), tag("PREMIUM_FEE")), + }, outputDebit: { expr: mul(tag("OUTPUT_RATE"), 50) }, }); diff --git a/packages/scrawn/tests/unit/utils/logger.test.ts b/packages/scrawn/tests/unit/utils/logger.test.ts index b32c17e..ad36116 100644 --- a/packages/scrawn/tests/unit/utils/logger.test.ts +++ b/packages/scrawn/tests/unit/utils/logger.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vitest"; describe("ScrawnLogger", () => { it("logs to console with context", async () => { - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const consoleSpy = vi + .spyOn(console, "log") + .mockImplementation(() => undefined); const { ScrawnLogger } = await import("../../../src/utils/logger.js"); const logger = new ScrawnLogger("Test"); @@ -13,7 +15,9 @@ describe("ScrawnLogger", () => { }); it("skips debug logs when disabled", async () => { - const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + const consoleSpy = vi + .spyOn(console, "log") + .mockImplementation(() => undefined); delete process.env.SCRAWN_DEBUG; const { ScrawnLogger } = await import("../../../src/utils/logger.js"); diff --git a/tsconfig.json b/tsconfig.json index df68ff5..6e37e3d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,23 +1,23 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "Bundler", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": ".", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "paths": { - "@scrawn/core": ["./packages/scrawn/src/index.ts"], - "@scrawn/sdk_call": ["./packages/@scrawn/sdk_call/src/index.ts"] - } - }, - "include": ["packages/*/src/**/*", "examples/**/*"], - "exclude": ["node_modules", "**/dist", "**/node_modules"] -} +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "paths": { + "@scrawn/core": ["./packages/scrawn/src/index.ts"], + "@scrawn/sdk_call": ["./packages/@scrawn/sdk_call/src/index.ts"] + } + }, + "include": ["packages/*/src/**/*", "examples/**/*"], + "exclude": ["node_modules", "**/dist", "**/node_modules"] +}