diff --git a/apps/cli/config.toml.example b/apps/cli/config.toml.example index a27f12ce..c0b8b5e1 100644 --- a/apps/cli/config.toml.example +++ b/apps/cli/config.toml.example @@ -9,6 +9,15 @@ local_host = "localhost" subdomain = "my-app" custom_domain = "app.example.com" +[tunnel.web.shadow] +target_port = 4000 +target_host = "localhost" +target_protocol = "http" +sample_rate = 1 +timeout_ms = 4000 +max_body_bytes = 262144 +compare_headers = ["content-type", "cache-control"] + [tunnel.api] protocol = "http" local_port = 8000 @@ -24,4 +33,3 @@ remote_port = 20000 protocol = "udp" local_port = 5000 remote_port = 30000 - diff --git a/apps/cli/package.json b/apps/cli/package.json index 6f7e9f16..729bcaa5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,7 +54,7 @@ ], "dependencies": { "@iarna/toml": "^2.2.5", - "@outray/core": "^0.0.1", + "@outray/core": "file:../../packages/core", "@types/prompts": "^2.4.9", "chalk": "^4.1.2", "http-proxy": "^1.18.1", diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 9711bc26..e2007df0 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -2,8 +2,19 @@ import WebSocket from "ws"; import chalk from "chalk"; import prompts from "prompts"; import { encodeMessage, decodeMessage } from "@outray/core"; -import type { TunnelDataMessage, TunnelResponseMessage } from "@outray/core"; +import type { + ShadowDiffResult, + ShadowOptions, + ShadowResponseSummary, + TunnelDataMessage, + TunnelResponseMessage, +} from "@outray/core"; import http from "http"; +import https from "https"; +import { createHash, randomUUID } from "crypto"; + +const DEFAULT_SHADOW_TIMEOUT_MS = 4000; +const DEFAULT_SHADOW_MAX_BODY_BYTES = 256 * 1024; export class OutRayClient { private ws: WebSocket | null = null; @@ -23,6 +34,7 @@ export class OutRayClient { private reconnectAttempts = 0; private lastPongReceived = Date.now(); private noLog: boolean; + private shadow?: ShadowOptions; private readonly PING_INTERVAL_MS = 25000; // 25 seconds private readonly PONG_TIMEOUT_MS = 10000; // 10 seconds to wait for pong @@ -33,6 +45,7 @@ export class OutRayClient { subdomain?: string, customDomain?: string, noLog: boolean = false, + shadow?: ShadowOptions, ) { this.localPort = localPort; this.serverUrl = serverUrl; @@ -41,6 +54,7 @@ export class OutRayClient { this.customDomain = customDomain; this.requestedSubdomain = subdomain; this.noLog = noLog; + this.shadow = shadow; } public start(): void { @@ -183,6 +197,7 @@ export class OutRayClient { private handleTunnelData(message: TunnelDataMessage): void { const startTime = Date.now(); + const requestId = randomUUID(); const reqOptions = { hostname: "localhost", port: this.localPort, @@ -230,6 +245,28 @@ export class OutRayClient { }; this.ws?.send(encodeMessage(response)); + + const shadowOptions = this.shadow; + if ( + shadowOptions && + shadowOptions.enabled !== false && + this.shouldSample(shadowOptions) + ) { + void this.runShadowDiff({ + requestId, + method: message.method, + path: message.path, + headers: message.headers, + bodyBuffer, + primary: this.buildResponseSummary( + statusCode, + res.headers, + bodyBuffer, + duration, + ), + options: shadowOptions, + }); + } }); }); @@ -276,6 +313,215 @@ export class OutRayClient { } } + private shouldSample(options: ShadowOptions): boolean { + const rate = options.sampleRate ?? 1; + if (rate >= 1) return true; + if (rate <= 0) return false; + return Math.random() < rate; + } + + private buildResponseSummary( + statusCode: number, + headers: http.IncomingHttpHeaders, + bodyBuffer: Buffer, + durationMs: number, + ): ShadowResponseSummary { + const maxBytes = + this.shadow?.maxBodyBytes ?? DEFAULT_SHADOW_MAX_BODY_BYTES; + const bodySlice = bodyBuffer.subarray(0, maxBytes); + const truncated = bodyBuffer.length > bodySlice.length; + const bodyHash = bodySlice.length + ? createHash("sha256").update(bodySlice).digest("hex") + : undefined; + return { + statusCode, + headers: headers as Record, + bodyHash, + bodyBytes: bodyBuffer.length, + durationMs, + truncated, + }; + } + + private async runShadowDiff(args: { + requestId: string; + method: string; + path: string; + headers: Record; + bodyBuffer: Buffer; + primary: ShadowResponseSummary; + options: ShadowOptions; + }): Promise { + const { requestId, method, path, headers, bodyBuffer, primary, options } = + args; + + const shadow = await this.forwardToShadow({ + method, + path, + headers, + bodyBuffer, + options, + }); + + const diffs = this.compareResponses(primary, shadow, options); + + if (diffs.status || diffs.body || diffs.headers.length > 0) { + const diffResult: ShadowDiffResult = { + requestId, + method, + path, + primary, + shadow, + differences: diffs, + }; + this.logShadowDiff(diffResult); + } + } + + private compareResponses( + primary: ShadowResponseSummary, + shadow: ShadowResponseSummary, + options: ShadowOptions, + ): ShadowDiffResult["differences"] { + const status = primary.statusCode !== shadow.statusCode; + const body = primary.bodyHash !== shadow.bodyHash; + + const headerKeys = options.compareHeaders; + if (!headerKeys || headerKeys.length === 0) { + return { + status, + headers: [], + body, + }; + } + + const mismatched: string[] = []; + for (const key of headerKeys) { + const normalized = key.toLowerCase(); + const p = primary.headers?.[normalized] ?? primary.headers?.[key]; + const s = shadow.headers?.[normalized] ?? shadow.headers?.[key]; + if (JSON.stringify(p) !== JSON.stringify(s)) { + mismatched.push(key); + } + } + + return { + status, + headers: mismatched, + body, + }; + } + + private logShadowDiff(result: ShadowDiffResult): void { + const parts: string[] = []; + if (result.differences.status) { + parts.push( + `status ${result.primary.statusCode ?? "?"}→${result.shadow.statusCode ?? "?"}`, + ); + } + if (result.differences.body) { + parts.push("body"); + } + if (result.differences.headers.length > 0) { + parts.push(`headers ${result.differences.headers.join(",")}`); + } + + const shadowError = result.shadow.error + ? chalk.red(` shadow_error=${result.shadow.error}`) + : ""; + const timings = ` ${chalk.dim( + `(${result.primary.durationMs ?? "-"}ms/${result.shadow.durationMs ?? "-"}ms)`, + )}`; + + console.log( + chalk.yellow("⚡ Shadow diff:") + + ` ${chalk.bold(result.method)} ${result.path} ` + + chalk.yellow(parts.join(", ")) + + timings + + shadowError, + ); + } + + private forwardToShadow(args: { + method: string; + path: string; + headers: Record; + bodyBuffer: Buffer; + options: ShadowOptions; + }): Promise { + const { method, path, headers, bodyBuffer, options } = args; + const protocol = options.target.protocol ?? "http"; + const requestModule = protocol === "https" ? https : http; + const shadowHeaders = { ...headers } as Record; + delete shadowHeaders["host"]; + + return new Promise((resolve) => { + const start = Date.now(); + const timeoutMs = options.timeoutMs ?? DEFAULT_SHADOW_TIMEOUT_MS; + const maxBytes = options.maxBodyBytes ?? DEFAULT_SHADOW_MAX_BODY_BYTES; + const timer = setTimeout(() => { + resolve({ + error: "Shadow request timed out", + durationMs: Date.now() - start, + }); + }, timeoutMs); + + const req = requestModule.request( + { + hostname: options.target.host ?? "localhost", + port: options.target.port, + path, + method, + headers: shadowHeaders, + }, + (res) => { + const chunks: Buffer[] = []; + let bytes = 0; + + res.on("data", (chunk) => { + const bufferChunk = Buffer.from(chunk); + if (bytes < maxBytes) { + const remaining = maxBytes - bytes; + chunks.push(bufferChunk.subarray(0, remaining)); + } + bytes += bufferChunk.length; + }); + + res.on("end", () => { + clearTimeout(timer); + const bodyBuffer = Buffer.concat(chunks); + const durationMs = Date.now() - start; + const bodyHash = bodyBuffer.length + ? createHash("sha256").update(bodyBuffer).digest("hex") + : undefined; + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + bodyHash, + bodyBytes: bytes, + durationMs, + truncated: bytes > maxBytes, + }); + }); + }, + ); + + req.on("error", (error) => { + clearTimeout(timer); + resolve({ + error: error.message, + durationMs: Date.now() - start, + }); + }); + + if (bodyBuffer.length > 0) { + req.write(bodyBuffer); + } + + req.end(); + }); + } + private startPing(): void { this.stopPing(); this.lastPongReceived = Date.now(); diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 106000cc..d51efac0 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -9,6 +9,7 @@ import { ConfigManager, OutRayConfig } from "./config"; import { AuthManager } from "./auth"; import { TomlConfigParser, ParsedTunnelConfig } from "./toml-config"; import { version } from "../package.json"; +import type { ShadowOptions } from "@outray/core"; function getFlagValue(args: string[], flag: string): string | undefined { const match = args.find( @@ -33,6 +34,61 @@ function hasFlag(args: string[], flag: string): boolean { return args.includes(flag); } +function parseShadowOptionsFromArgs( + args: string[], +): ShadowOptions | undefined { + const shadowPortValue = getFlagValue(args, "--shadow-port"); + if (!shadowPortValue) { + return undefined; + } + + const shadowPort = parseInt(shadowPortValue, 10); + if (Number.isNaN(shadowPort) || shadowPort < 1 || shadowPort > 65535) { + throw new Error("shadow-port must be a valid port (1-65535)"); + } + + const targetHost = getFlagValue(args, "--shadow-host") || "localhost"; + const targetProtocol = + (getFlagValue(args, "--shadow-protocol") as "http" | "https" | undefined) ?? + "http"; + + if (targetProtocol !== "http" && targetProtocol !== "https") { + throw new Error("shadow-protocol must be http or https"); + } + + const sampleRateValue = getFlagValue(args, "--shadow-sample"); + const sampleRate = sampleRateValue ? parseFloat(sampleRateValue) : undefined; + if (sampleRate !== undefined && (sampleRate < 0 || sampleRate > 1)) { + throw new Error("shadow-sample must be between 0 and 1"); + } + + const timeoutValue = getFlagValue(args, "--shadow-timeout"); + const timeoutMs = timeoutValue ? parseInt(timeoutValue, 10) : undefined; + + const maxBodyValue = getFlagValue(args, "--shadow-max-body"); + const maxBodyBytes = maxBodyValue ? parseInt(maxBodyValue, 10) : undefined; + + const headerValue = getFlagValue(args, "--shadow-headers"); + const compareHeaders = headerValue + ? headerValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + : undefined; + + return { + target: { + host: targetHost, + port: shadowPort, + protocol: targetProtocol, + }, + sampleRate, + timeoutMs, + maxBodyBytes, + compareHeaders, + }; +} + async function handleLogin( configManager: ConfigManager, webUrl: string, @@ -344,12 +400,28 @@ async function handleStartFromConfig( tunnel.remotePort, ); } else { + const shadowOptions = tunnel.shadow + ? { + target: { + host: tunnel.shadow.target_host || "localhost", + port: tunnel.shadow.target_port, + protocol: tunnel.shadow.target_protocol || "http", + }, + sampleRate: tunnel.shadow.sample_rate, + timeoutMs: tunnel.shadow.timeout_ms, + maxBodyBytes: tunnel.shadow.max_body_bytes, + compareHeaders: tunnel.shadow.compare_headers, + } + : undefined; + client = new OutRayClient( tunnel.localPort, serverUrl, apiKey, tunnel.subdomain, tunnel.customDomain, + false, + shadowOptions, ); } @@ -410,6 +482,13 @@ function printHelp() { console.log( chalk.cyan(" --no-logs Disable tunnel request logs"), ); + console.log(chalk.cyan(" --shadow-port Mirror traffic to a local port (HTTP only)")); + console.log(chalk.cyan(" --shadow-host Shadow target host (default: localhost)")); + console.log(chalk.cyan(" --shadow-protocol

Shadow target protocol: http|https")); + console.log(chalk.cyan(" --shadow-sample Shadow sample rate 0-1 (default: 1)")); + console.log(chalk.cyan(" --shadow-timeout Shadow request timeout in ms")); + console.log(chalk.cyan(" --shadow-max-body Shadow body cap in bytes")); + console.log(chalk.cyan(" --shadow-headers Comma list of headers to diff")); console.log(chalk.cyan(" --dev Use dev environment")); console.log(chalk.cyan(" -v, --version Show version")); console.log(chalk.cyan(" -h, --help Show this help message")); @@ -510,6 +589,11 @@ async function main() { if (tunnel.org) { console.log(` Org: ${tunnel.org}`); } + if (tunnel.shadow) { + console.log( + ` Shadow: ${tunnel.shadow.target_protocol || "http"}://${tunnel.shadow.target_host || "localhost"}:${tunnel.shadow.target_port}`, + ); + } console.log(); } } catch (error) { @@ -595,6 +679,23 @@ async function main() { // Handle --no-logs flag to disable tunnel request logs const noLogs = hasFlag(remainingArgs, "--no-logs"); + let shadowOptions: ShadowOptions | undefined; + try { + shadowOptions = parseShadowOptionsFromArgs(remainingArgs); + } catch (error) { + console.log( + chalk.red( + `❌ ${error instanceof Error ? error.message : "Invalid shadow options"}`, + ), + ); + process.exit(1); + } + + if (shadowOptions && tunnelProtocol !== "http") { + console.log(chalk.red("❌ Shadow traffic is only supported for HTTP tunnels")); + process.exit(1); + } + // Load and validate config let config = configManager.load(); @@ -682,6 +783,7 @@ async function main() { subdomain, customDomain, noLogs, + shadowOptions, ); } diff --git a/apps/cli/src/toml-config.ts b/apps/cli/src/toml-config.ts index 187ee952..5a42731f 100644 --- a/apps/cli/src/toml-config.ts +++ b/apps/cli/src/toml-config.ts @@ -5,6 +5,16 @@ import Joi from "joi"; export type TunnelProtocol = "http" | "tcp" | "udp"; +export interface ShadowConfig { + target_host?: string; + target_port: number; + target_protocol?: "http" | "https"; + sample_rate?: number; + timeout_ms?: number; + max_body_bytes?: number; + compare_headers?: string[]; +} + export interface TunnelConfig { protocol: TunnelProtocol; local_port: number; @@ -13,6 +23,7 @@ export interface TunnelConfig { custom_domain?: string; remote_port?: number; org?: string; + shadow?: ShadowConfig; } export interface GlobalConfig { @@ -34,6 +45,7 @@ export interface ParsedTunnelConfig { customDomain?: string; remotePort?: number; org?: string; + shadow?: ShadowConfig; } const portSchema = Joi.number().integer().min(1).max(65535).required(); @@ -45,6 +57,18 @@ const globalConfigSchema = Joi.object({ }), }); +const shadowConfigSchema = Joi.object({ + target_host: Joi.string().hostname().optional().default("localhost"), + target_port: portSchema.messages({ + "any.required": "Shadow target port is required", + }), + target_protocol: Joi.string().valid("http", "https").optional().default("http"), + sample_rate: Joi.number().min(0).max(1).optional(), + timeout_ms: Joi.number().integer().min(1).optional(), + max_body_bytes: Joi.number().integer().min(1).optional(), + compare_headers: Joi.array().items(Joi.string()).optional(), +}); + const tunnelConfigSchema = Joi.object({ protocol: Joi.string() .valid("http", "tcp", "udp") @@ -65,6 +89,7 @@ const tunnelConfigSchema = Joi.object({ custom_domain: Joi.string().hostname().optional(), remote_port: Joi.number().integer().min(1).max(65535).optional(), org: Joi.string().optional(), + shadow: shadowConfigSchema.optional(), }).custom((value: TunnelConfig, helpers: Joi.CustomHelpers) => { const protocol = value.protocol; @@ -87,6 +112,11 @@ const tunnelConfigSchema = Joi.object({ message: `custom_domain is not valid for ${protocol.toUpperCase()} tunnels. ${protocol.toUpperCase()} tunnels use ports, not domains.`, }); } + if (value.shadow !== undefined) { + return helpers.error("any.invalid", { + message: `shadow is not valid for ${protocol.toUpperCase()} tunnels. Shadow traffic only applies to HTTP tunnels.`, + }); + } } return value; @@ -193,6 +223,7 @@ export class TomlConfigParser { customDomain: tunnel.custom_domain, remotePort: tunnel.remote_port, org: tunnel.org || globalConfig?.org, + shadow: tunnel.shadow, }); } diff --git a/apps/web/content/docs/(platform-features)/observability.mdx b/apps/web/content/docs/(platform-features)/observability.mdx index 44f8114c..2d3e2fff 100644 --- a/apps/web/content/docs/(platform-features)/observability.mdx +++ b/apps/web/content/docs/(platform-features)/observability.mdx @@ -35,6 +35,24 @@ OutRay stores your request history so you can analyze past traffic patterns. The - **Beam Plan**: 30 days retention - **Pulse Plan**: 90 days retention +## Shadow Traffic + Diff + +Shadow traffic lets you mirror live tunnel requests to a second local service and compare responses without affecting real users. This is useful for validating refactors, migrations, and new versions in real time. + +Key ideas: + +- **Non-blocking**: The primary response is returned as normal; shadow runs in parallel. +- **Diffs only**: You only see output when status, body hash, or selected headers differ. +- **Configurable**: Sampling, timeouts, and body caps keep it fast and safe. + +Example: + +```bash +outray http 3000 --shadow-port 4000 --shadow-headers content-type,cache-control +``` + +When differences occur, the CLI prints a concise diff line. + {/* ## Inspecting Requests */} {/* You can click on any request in the log to view more details, including headers and query parameters. This is particularly useful for debugging webhook payloads or API integrations. */} diff --git a/apps/web/content/docs/(plugins-and-sdks)/nextjs-plugin.mdx b/apps/web/content/docs/(plugins-and-sdks)/nextjs-plugin.mdx index 1c519640..c49c7a25 100644 --- a/apps/web/content/docs/(plugins-and-sdks)/nextjs-plugin.mdx +++ b/apps/web/content/docs/(plugins-and-sdks)/nextjs-plugin.mdx @@ -69,6 +69,7 @@ export default withOutray( | `serverUrl` | `string` | `wss://api.outray.dev/` | OutRay server WebSocket URL. Only change this for self-hosted instances. | | `enabled` | `boolean` | `process.env.OUTRAY_ENABLED !== "false"` | Enable or disable the tunnel. | | `silent` | `boolean` | `false` | Suppress tunnel status logs. | +| `shadow` | `ShadowOptions` | — | Mirror traffic to a local shadow target and diff responses (HTTP only). | | `onTunnelReady` | `(url: string) => void` | — | Callback fired when tunnel is successfully established. | | `onError` | `(error: Error) => void` | — | Callback fired when tunnel encounters an error. | | `onClose` | `() => void` | — | Callback fired when tunnel connection is closed. | @@ -140,6 +141,28 @@ export default withOutray( ) ``` +### Shadow Traffic + Diff + +Mirror requests to another local service and log diffs when responses diverge: + +```ts +// next.config.ts +import withOutray from '@outray/next' + +export default withOutray( + {}, + { + shadow: { + target: { host: 'localhost', port: 4000, protocol: 'http' }, + sampleRate: 1, + timeoutMs: 4000, + maxBodyBytes: 262144, + compareHeaders: ['content-type'], + }, + } +) +``` + ### With Callbacks React to tunnel events in your application: diff --git a/apps/web/content/docs/(plugins-and-sdks)/vite-plugin.mdx b/apps/web/content/docs/(plugins-and-sdks)/vite-plugin.mdx index 5a96feb7..f5368913 100644 --- a/apps/web/content/docs/(plugins-and-sdks)/vite-plugin.mdx +++ b/apps/web/content/docs/(plugins-and-sdks)/vite-plugin.mdx @@ -69,6 +69,7 @@ export default defineConfig({ | `serverUrl` | `string` | `wss://api.outray.dev/` | OutRay server WebSocket URL. Only change this for self-hosted instances. | | `enabled` | `boolean` | `process.env.OUTRAY_ENABLED !== "false"` | Enable or disable the tunnel. | | `silent` | `boolean` | `false` | Suppress tunnel status logs. | +| `shadow` | `ShadowOptions` | — | Mirror traffic to a local shadow target and diff responses (HTTP only). | | `onTunnelReady` | `(url: string) => void` | — | Callback fired when tunnel is successfully established. | | `onError` | `(error: Error) => void` | — | Callback fired when tunnel encounters an error. | | `onClose` | `() => void` | — | Callback fired when tunnel connection is closed. | @@ -140,6 +141,29 @@ export default defineConfig({ }) ``` +### Shadow Traffic + Diff + +Mirror requests to another local service and log diffs when responses diverge: + +```ts +import { defineConfig } from 'vite' +import outray from '@outray/vite' + +export default defineConfig({ + plugins: [ + outray({ + shadow: { + target: { host: 'localhost', port: 4000, protocol: 'http' }, + sampleRate: 1, + timeoutMs: 4000, + maxBodyBytes: 262144, + compareHeaders: ['content-type'], + }, + }) + ] +}) +``` + ### With Callbacks React to tunnel events in your application: diff --git a/apps/web/content/docs/(reference)/cli-reference.mdx b/apps/web/content/docs/(reference)/cli-reference.mdx index 7674faaf..a685436a 100644 --- a/apps/web/content/docs/(reference)/cli-reference.mdx +++ b/apps/web/content/docs/(reference)/cli-reference.mdx @@ -71,6 +71,13 @@ outray 3000 - `--org `: Run the tunnel under a specific organization. - `--key `: Use a specific API key instead of the logged-in user. - `--no-logs`: Disable tunnel request logs. +- `--shadow-port `: Mirror traffic to another local port (HTTP only). +- `--shadow-host `: Shadow target host (default: `localhost`). +- `--shadow-protocol `: Shadow target protocol (default: `http`). +- `--shadow-sample <0-1>`: Shadow sampling rate (default: `1`). +- `--shadow-timeout `: Shadow request timeout. +- `--shadow-max-body `: Shadow body cap in bytes. +- `--shadow-headers `: Comma-separated header names to diff. ### `outray switch` @@ -120,6 +127,15 @@ local_host = "localhost" subdomain = "my-app" custom_domain = "app.example.com" +[tunnel.web.shadow] +target_port = 4000 +target_host = "localhost" +target_protocol = "http" +sample_rate = 1 +timeout_ms = 4000 +max_body_bytes = 262144 +compare_headers = ["content-type", "cache-control"] + [tunnel.api] protocol = "http" local_port = 8000 @@ -153,6 +169,17 @@ remote_port = 30000 - `custom_domain`: Custom domain for HTTP tunnels (optional, HTTP only) - `remote_port`: Remote port for TCP/UDP tunnels (optional, TCP/UDP only) - `org`: Organization slug for this tunnel (optional, overrides global setting) +- `shadow`: Shadow traffic config for HTTP tunnels (optional) + +**Shadow Settings (HTTP only):** + +- `target_port`: Local port to mirror requests to (required) +- `target_host`: Shadow target host (optional, defaults to `"localhost"`) +- `target_protocol`: Shadow target protocol - `"http"` or `"https"` (optional, defaults to `"http"`) +- `sample_rate`: Sampling rate between 0 and 1 (optional, defaults to 1) +- `timeout_ms`: Shadow request timeout in milliseconds (optional) +- `max_body_bytes`: Maximum body size to hash/diff (optional) +- `compare_headers`: Header names to diff (optional) ### Example Usage @@ -175,3 +202,4 @@ These flags can be used with most commands: - `--help`, `-h`: Display help information. - `--config `: Path to config file (for `start` and `validate-config` commands). - `--no-logs`: Disable tunnel request logs (for tunnel commands). +- `--shadow-port `: Mirror traffic to another local port (HTTP only). diff --git a/package-lock.json b/package-lock.json index 43c54cb0..76726790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", - "@outray/core": "^0.0.1", + "@outray/core": "file:../../packages/core", "@types/prompts": "^2.4.9", "chalk": "^4.1.2", "http-proxy": "^1.18.1", @@ -217,7 +217,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -268,7 +267,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -591,14 +589,12 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@better-fetch/fetch": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz", - "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==", - "peer": true + "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" }, "node_modules/@braintree/sanitize-url": { "version": "7.1.1", @@ -2176,16 +2172,8 @@ } }, "node_modules/@outray/core": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@outray/core/-/core-0.0.1.tgz", - "integrity": "sha512-3TEe14CW/kQRxtoO/0TDreWR8x7FTJcmtb+Erl4yzwgPji9sqRaENCdnbQX+Bp45j5ysEmCaLZznerENKAurAA==", - "license": "MIT", - "dependencies": { - "ws": "^8.18.0" - }, - "engines": { - "node": ">=16.0.0" - } + "resolved": "packages/core", + "link": true }, "node_modules/@outray/vite": { "version": "0.0.3", @@ -3740,7 +3728,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -5453,7 +5440,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5464,7 +5450,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5489,7 +5474,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -5580,7 +5564,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -5868,7 +5851,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6121,7 +6103,6 @@ "version": "1.0.19", "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.0.19.tgz", "integrity": "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==", - "peer": true, "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", @@ -6257,7 +6238,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6771,7 +6751,6 @@ "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.1.tgz", "integrity": "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w==", "license": "MIT", - "peer": true, "peerDependencies": { "srvx": ">=0.7.1" }, @@ -6832,7 +6811,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -7233,7 +7211,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7349,7 +7326,6 @@ "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz", "integrity": "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==", "license": "MIT", - "peer": true, "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", @@ -7652,7 +7628,6 @@ "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", "integrity": "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", @@ -7942,7 +7917,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8019,7 +7993,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8606,7 +8579,6 @@ "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.4.6.tgz", "integrity": "sha512-cPmKu7HmzzAOXk4TbAfJhVQ12C36nu0A8sDPi664X35lOAMr+vBtjY6yIYrc8szPEFrBcmkVRGLZyEkNDZWE/Q==", "license": "MIT", - "peer": true, "dependencies": { "@formatjs/intl-localematcher": "^0.7.5", "@orama/orama": "^3.1.18", @@ -9843,7 +9815,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -10112,7 +10083,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -10135,7 +10105,6 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -10280,7 +10249,6 @@ "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.9.tgz", "integrity": "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" } @@ -10665,7 +10633,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -10675,7 +10642,6 @@ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.560.0.tgz", "integrity": "sha512-NwKoUA/aBShsdL8WE5lukV2F/tjHzQRlonQs7fkNGI1sCT0Ay4a9Ap3ST2clUUkcY+9eQ0pBe2hybTQd2fmyDA==", "license": "ISC", - "peer": true, "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -12333,8 +12299,7 @@ "version": "2.0.0-alpha.3", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-2.0.0-alpha.3.tgz", "integrity": "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ohash": { "version": "2.0.11", @@ -12655,7 +12620,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -12937,7 +12901,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13285,7 +13248,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13295,7 +13257,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13340,7 +13301,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13634,8 +13594,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -14025,7 +13984,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.2.tgz", "integrity": "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -14537,8 +14495,7 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14631,7 +14588,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15372,7 +15328,6 @@ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -15931,7 +15886,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16326,7 +16280,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16895,7 +16848,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17199,7 +17151,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17255,6 +17206,23 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/core": { + "name": "@outray/core", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/ws": "^8.5.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } } } } diff --git a/packages/core/package-lock.json b/packages/core/package-lock.json index 65601b7e..77f82e48 100644 --- a/packages/core/package-lock.json +++ b/packages/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@outray/core", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@outray/core", - "version": "0.1.0", + "version": "0.0.1", "license": "MIT", "dependencies": { "ws": "^8.18.0" @@ -993,7 +993,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1186,7 +1185,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1484,7 +1482,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 1ac438cf..97c5917c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,16 +1,22 @@ import WebSocket from "ws"; import http from "http"; +import https from "https"; +import { createHash, randomUUID } from "crypto"; import { encodeMessage, decodeMessage } from "./protocol"; import type { OutrayClientOptions, TunnelDataMessage, TunnelResponseMessage, - ErrorCodes, + ShadowDiffResult, + ShadowOptions, + ShadowResponseSummary, } from "./types"; const DEFAULT_SERVER_URL = "wss://api.outray.dev/"; const PING_INTERVAL_MS = 25000; const PONG_TIMEOUT_MS = 10000; +const DEFAULT_SHADOW_TIMEOUT_MS = 4000; +const DEFAULT_SHADOW_MAX_BODY_BYTES = 256 * 1024; /** * Core Outray tunnel client. @@ -172,6 +178,7 @@ export class OutrayClient { private handleTunnelData(message: TunnelDataMessage): void { const startTime = Date.now(); + const requestId = randomUUID(); const reqOptions = { hostname: "localhost", @@ -212,6 +219,28 @@ export class OutrayClient { }; this.ws?.send(encodeMessage(response)); + + const shadowOptions = this.options.shadow; + if ( + shadowOptions && + shadowOptions.enabled !== false && + this.shouldSample(shadowOptions) + ) { + void this.runShadowDiff({ + requestId, + method: message.method, + path: message.path, + headers: message.headers, + bodyBuffer, + primary: this.buildResponseSummary( + statusCode, + res.headers, + bodyBuffer, + duration, + ), + options: shadowOptions, + }); + } }); }); @@ -255,6 +284,185 @@ export class OutrayClient { } } + private shouldSample(options: ShadowOptions): boolean { + const rate = options.sampleRate ?? 1; + if (rate >= 1) return true; + if (rate <= 0) return false; + return Math.random() < rate; + } + + private buildResponseSummary( + statusCode: number, + headers: http.IncomingHttpHeaders, + bodyBuffer: Buffer, + durationMs: number, + ): ShadowResponseSummary { + const maxBytes = + this.options.shadow?.maxBodyBytes ?? DEFAULT_SHADOW_MAX_BODY_BYTES; + const bodySlice = bodyBuffer.subarray(0, maxBytes); + const truncated = bodyBuffer.length > bodySlice.length; + const bodyHash = bodySlice.length + ? createHash("sha256").update(bodySlice).digest("hex") + : undefined; + return { + statusCode, + headers: headers as Record, + bodyHash, + bodyBytes: bodyBuffer.length, + durationMs, + truncated, + }; + } + + private async runShadowDiff(args: { + requestId: string; + method: string; + path: string; + headers: Record; + bodyBuffer: Buffer; + primary: ShadowResponseSummary; + options: ShadowOptions; + }): Promise { + const { requestId, method, path, headers, bodyBuffer, primary, options } = + args; + + const shadow = await this.forwardToShadow({ + method, + path, + headers, + bodyBuffer, + options, + }); + + const diffs = this.compareResponses(primary, shadow, options); + + if (diffs.status || diffs.body || diffs.headers.length > 0) { + const diffResult: ShadowDiffResult = { + requestId, + method, + path, + primary, + shadow, + differences: diffs, + }; + this.options.onShadowDiff?.(diffResult); + } + } + + private compareResponses( + primary: ShadowResponseSummary, + shadow: ShadowResponseSummary, + options: ShadowOptions, + ): ShadowDiffResult["differences"] { + const status = primary.statusCode !== shadow.statusCode; + const body = primary.bodyHash !== shadow.bodyHash; + + const headerKeys = options.compareHeaders; + if (!headerKeys || headerKeys.length === 0) { + return { + status, + headers: [], + body, + }; + } + + const mismatched: string[] = []; + for (const key of headerKeys) { + const normalized = key.toLowerCase(); + const p = primary.headers?.[normalized] ?? primary.headers?.[key]; + const s = shadow.headers?.[normalized] ?? shadow.headers?.[key]; + if (JSON.stringify(p) !== JSON.stringify(s)) { + mismatched.push(key); + } + } + + return { + status, + headers: mismatched, + body, + }; + } + + private forwardToShadow(args: { + method: string; + path: string; + headers: Record; + bodyBuffer: Buffer; + options: ShadowOptions; + }): Promise { + const { method, path, headers, bodyBuffer, options } = args; + const protocol = options.target.protocol ?? "http"; + const requestModule = protocol === "https" ? https : http; + const shadowHeaders = { ...headers }; + delete shadowHeaders["host"]; + + return new Promise((resolve) => { + const start = Date.now(); + const timeoutMs = options.timeoutMs ?? DEFAULT_SHADOW_TIMEOUT_MS; + const maxBytes = options.maxBodyBytes ?? DEFAULT_SHADOW_MAX_BODY_BYTES; + const timer = setTimeout(() => { + resolve({ + error: "Shadow request timed out", + durationMs: Date.now() - start, + }); + }, timeoutMs); + + const req = requestModule.request( + { + hostname: options.target.host ?? "localhost", + port: options.target.port, + path, + method, + headers: shadowHeaders, + }, + (res) => { + const chunks: Buffer[] = []; + let bytes = 0; + + res.on("data", (chunk) => { + const bufferChunk = Buffer.from(chunk); + if (bytes < maxBytes) { + const remaining = maxBytes - bytes; + chunks.push(bufferChunk.subarray(0, remaining)); + } + bytes += bufferChunk.length; + }); + + res.on("end", () => { + clearTimeout(timer); + const bodyBuffer = Buffer.concat(chunks); + const durationMs = Date.now() - start; + const bodyHash = bodyBuffer.length + ? createHash("sha256").update(bodyBuffer).digest("hex") + : undefined; + resolve({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + bodyHash, + bodyBytes: bytes, + durationMs, + truncated: bytes > maxBytes, + }); + }); + }, + ); + + req.on("error", (error) => { + clearTimeout(timer); + resolve({ + error: error.message, + durationMs: Date.now() - start, + }); + }); + + if (bodyBuffer.length > 0) { + req.write(bodyBuffer); + } + + req.end(); + }); + } + private startPing(): void { this.stopPing(); this.lastPongReceived = Date.now(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04e73271..40a2584a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,10 @@ export type { // Client options OutrayClientOptions, RequestInfo, + ShadowOptions, + ShadowTarget, + ShadowDiffResult, + ShadowResponseSummary, TunnelProtocol, // Protocol messages ClientMessage, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9b0d3d56..0c94754d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -74,6 +74,16 @@ export interface OutrayClientOptions { * Callback fired for each proxied request (HTTP only) */ onRequest?: (info: RequestInfo) => void; + + /** + * Shadow traffic options (HTTP only) + */ + shadow?: ShadowOptions; + + /** + * Callback fired when shadow traffic differs from primary response + */ + onShadowDiff?: (result: ShadowDiffResult) => void; } /** @@ -87,6 +97,48 @@ export interface RequestInfo { error?: string; } +// ============================================================================ +// Shadow Traffic +// ============================================================================ + +export interface ShadowTarget { + host?: string; + port: number; + protocol?: "http" | "https"; +} + +export interface ShadowOptions { + target: ShadowTarget; + enabled?: boolean; + sampleRate?: number; + timeoutMs?: number; + maxBodyBytes?: number; + compareHeaders?: string[]; +} + +export interface ShadowResponseSummary { + statusCode?: number; + headers?: Record; + bodyHash?: string; + bodyBytes?: number; + durationMs?: number; + error?: string; + truncated?: boolean; +} + +export interface ShadowDiffResult { + requestId: string; + method: string; + path: string; + primary: ShadowResponseSummary; + shadow: ShadowResponseSummary; + differences: { + status: boolean; + headers: string[]; + body: boolean; + }; +} + // ============================================================================ // Protocol Messages - Client to Server // ============================================================================ diff --git a/packages/next-plugin/src/index.ts b/packages/next-plugin/src/index.ts index 3fb6258e..4aca0e1b 100644 --- a/packages/next-plugin/src/index.ts +++ b/packages/next-plugin/src/index.ts @@ -78,6 +78,7 @@ function startTunnel(options: OutrayPluginOptions, silent: boolean): void { apiKey, subdomain, customDomain: options.customDomain, + shadow: options.shadow, onTunnelReady: (url) => { if (!silent) { const colorUrl = `\x1b[36m${url}\x1b[0m`; diff --git a/packages/next-plugin/src/types.ts b/packages/next-plugin/src/types.ts index 68eb07b8..4bd68729 100644 --- a/packages/next-plugin/src/types.ts +++ b/packages/next-plugin/src/types.ts @@ -1,3 +1,5 @@ +import type { ShadowOptions } from "@outray/core"; + /** * Configuration options for the Outray Next.js plugin */ @@ -59,4 +61,9 @@ export interface OutrayPluginOptions { * Callback fired when tunnel is attempting to reconnect */ onReconnecting?: () => void; + + /** + * Shadow traffic options (HTTP only) + */ + shadow?: ShadowOptions; } diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index 28228d36..b59ffae8 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -97,6 +97,7 @@ export default function outrayPlugin( apiKey, subdomain, customDomain: options.customDomain, + shadow: options.shadow, onTunnelReady: (url) => { if (!silent) { // Print tunnel URL in Vite's style diff --git a/packages/vite-plugin/src/types.ts b/packages/vite-plugin/src/types.ts index 6b5f7a86..0ec3bd96 100644 --- a/packages/vite-plugin/src/types.ts +++ b/packages/vite-plugin/src/types.ts @@ -1,4 +1,5 @@ import type { Plugin } from "vite"; +import type { ShadowOptions } from "@outray/core"; /** * Configuration options for the Outray Vite plugin @@ -61,6 +62,11 @@ export interface OutrayPluginOptions { * Callback fired when tunnel is attempting to reconnect */ onReconnecting?: () => void; + + /** + * Shadow traffic options (HTTP only) + */ + shadow?: ShadowOptions; } /**