Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,46 @@ Send standard JSON-RPC POST requests to the appropriate network path:
* `http://localhost:8000/mainnet`
* `http://localhost:8000/sepolia`
* etc.

## Architecture

Junction is built with a modular architecture for maintainability and testability:

### Core Components

- **`src/auth.ts`** - Handles authentication and bypass token validation
- **`src/routing.ts`** - URL pattern matching and network validation
- **`src/rpc_client.ts`** - RPC forwarding with timeout and fallback logic
- **`src/rate_limiter.ts`** - IP-based rate limiting implementation
- **`src/middleware.ts`** - Oak middleware for rate limiting
- **`src/config.ts`** - Environment-based configuration loading

### Server Implementations

Junction supports two server implementations:

1. **Oak Framework** (`src/server_oak.ts`) - Modern middleware-based approach with proper routing
2. **Original Deno.serve** (`src/server.ts`) - Legacy implementation for compatibility

By default, Junction uses the Oak-based server. To use the original implementation, set `USE_OAK_SERVER=false`.

### Testing

Run the test suite:

```bash
deno test --allow-net --allow-read --allow-env
```

The test suite includes:
- Unit tests for individual components
- Integration tests for complete request flows
- Fallback scenario testing (success, failure, timeout cases)

## Development

Start in development mode with auto-reload:

```bash
deno task dev
```
5 changes: 3 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"test": "deno test --allow-net --allow-read --allow-env"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1",
"@oak/oak": "https://deno.land/x/oak@v17.1.2/mod.ts",
"dotenv": "https://deno.land/std@0.224.0/dotenv/mod.ts",
"zod": "https://deno.land/x/zod@v3.23.4/mod.ts"
"zod": "https://deno.land/x/zod@v3.23.4/mod.ts",
"std/assert": "https://deno.land/std@0.224.0/assert/mod.ts"
}
}
218 changes: 101 additions & 117 deletions deno.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { loadAppConfig } from "./src/config.ts";
import { startServer } from "./src/server.ts";
import { startServer } from "./src/server_oak.ts";


if (import.meta.main) {
Expand Down
24 changes: 24 additions & 0 deletions main_compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { loadAppConfig } from "./src/config.ts";

// Support both server implementations for compatibility
const useOak = Deno.env.get("USE_OAK_SERVER") === "true";

if (import.meta.main) {
console.log("Loading application configuration...");
const appConfig = loadAppConfig();

if (appConfig) {
if (useOak) {
console.log("Starting Oak-based server...");
const { startServer } = await import("./src/server_oak.ts");
startServer(appConfig);
} else {
console.log("Starting original Deno.serve-based server...");
const { startServer } = await import("./src/server.ts");
startServer(appConfig);
}
} else {
console.error("❌ Server could not start due to configuration errors.");
Deno.exit(1);
}
}
42 changes: 42 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { RateLimitConfig } from "./config.ts";

export interface AuthResult {
isTrusted: boolean;
clientIp?: string;
}

/**
* Handles authentication and determines if a request is from a trusted source.
*
* @param authHeader The Authorization header value
* @param rateLimitConfig Rate limit configuration containing bypass token
* @param remoteAddr Connection information for client IP extraction
* @returns Authentication result indicating trust level and client IP
*/
export function authenticateRequest(
authHeader: string | null,
rateLimitConfig: RateLimitConfig,
remoteAddr: Deno.Addr,
): AuthResult {
let isTrusted = false;
let clientIp: string | undefined;

// Extract client IP for rate limiting
if (remoteAddr.transport === "tcp" || remoteAddr.transport === "udp") {
clientIp = (remoteAddr as Deno.NetAddr).hostname;
}

// Check for bypass token authentication
if (rateLimitConfig.bypassToken && authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring(7); // Length of "Bearer "
if (token === rateLimitConfig.bypassToken) {
isTrusted = true;
console.log(`[Auth] Trusted request via bypass token.`);
} else {
// Log invalid token attempt but treat as untrusted for rate limiting
console.warn(`[Auth] Invalid bypass token received from ${clientIp ?? 'unknown'}.`);
}
}

return { isTrusted, clientIp };
}
46 changes: 46 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Context } from "@oak/oak";
import type { RateLimitConfig } from "./config.ts";
import { checkRateLimit } from "./rate_limiter.ts";

/**
* Creates Oak middleware for rate limiting based on client IP and bypass tokens.
* This middleware should be applied before the main RPC handler.
*/
export function rateLimitMiddleware(rateLimitConfig: RateLimitConfig) {
return async (ctx: Context, next: () => Promise<unknown>) => {
// Check for bypass token authentication
const authHeader = ctx.request.headers.get("Authorization");
let isTrusted = false;

if (rateLimitConfig.bypassToken && authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring(7); // Length of "Bearer "
if (token === rateLimitConfig.bypassToken) {
isTrusted = true;
console.log(`[Auth] Trusted request via bypass token.`);
} else {
console.warn(`[Auth] Invalid bypass token received from ${ctx.request.ip || 'unknown'}.`);
}
}

// Apply rate limiting for non-trusted requests
if (!isTrusted && rateLimitConfig.enabled) {
const clientIp = ctx.request.ip;

if (clientIp && !checkRateLimit(clientIp, rateLimitConfig)) {
console.warn(`[RateLimit] IP ${clientIp} exceeded limit of ${rateLimitConfig.rpm} RPM.`);

ctx.response.status = 429;
ctx.response.headers.set("Retry-After", "60");
ctx.response.body = "Too Many Requests";
return;
} else if (!clientIp) {
console.warn(`[RateLimit] Cannot determine client IP for rate limiting.`);
}
}

// Set trust flag in context for downstream handlers
(ctx as any).isTrusted = isTrusted;

await next();
};
}
36 changes: 36 additions & 0 deletions src/routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const networkPattern = new URLPattern({ pathname: "/:slug" });

export interface RouteMatch {
slug: string;
isValid: boolean;
}

/**
* Extracts the network slug from a request URL using URL pattern matching.
*
* @param url The request URL to parse
* @returns Route match result with extracted slug and validity
*/
export function extractNetworkSlug(url: string): RouteMatch {
const urlObj = new URL(url);
const match = networkPattern.exec(urlObj);

const slug = match?.pathname?.groups?.slug;

if (!slug) {
return { slug: "unknown", isValid: false };
}

return { slug, isValid: true };
}

/**
* Validates that the requested network is configured in the RPC config.
*
* @param slug The network slug to validate
* @param rpcConfig The RPC configuration object
* @returns True if the network is configured, false otherwise
*/
export function isNetworkConfigured(slug: string, rpcConfig: Record<string, any[]>): boolean {
return slug in rpcConfig && Array.isArray(rpcConfig[slug]) && rpcConfig[slug].length > 0;
}
169 changes: 169 additions & 0 deletions src/rpc_client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
const RPC_TIMEOUT_MS = 10000; // 10 seconds timeout for upstream RPC calls

export interface RpcEndpoint {
url: string;
authToken?: string;
}

export interface RpcForwardResult {
success: boolean;
response?: Response;
error?: {
status: number;
body: string;
};
}

/**
* Forwards a JSON-RPC request to a single upstream endpoint with timeout handling.
*
* @param endpoint The RPC endpoint configuration
* @param requestBody The JSON-RPC request body to forward
* @param networkSlug The network identifier for logging
* @returns Result of the RPC forwarding attempt
*/
export async function forwardToRpcEndpoint(
endpoint: RpcEndpoint,
requestBody: any,
networkSlug: string
): Promise<RpcForwardResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);

try {
const headers = new Headers({
"Content-Type": "application/json",
});
if (endpoint.authToken) {
headers.set("Authorization", endpoint.authToken);
}

console.log(`[${networkSlug}] Attempting RPC: ${endpoint.url}`);

const response = await fetch(endpoint.url, {
method: "POST",
headers: headers,
body: JSON.stringify(requestBody),
signal: controller.signal,
});

clearTimeout(timeoutId);

const responseBodyText = await response.text();

if (response.ok) {
let responseBodyJson;
try {
responseBodyJson = JSON.parse(responseBodyText);
} catch (parseError) {
const message = parseError instanceof Error ? parseError.message : String(parseError);
console.error(`[${networkSlug}] Failed to parse JSON response from ${endpoint.url}: ${message}. Body: ${responseBodyText.substring(0, 200)}...`);

return {
success: false,
error: {
status: 502,
body: `Bad Gateway: Upstream response from ${endpoint.url} was not valid JSON.`
}
};
}

if (responseBodyJson && typeof responseBodyJson === 'object' && 'error' in responseBodyJson) {
console.warn(`[${networkSlug}] RPC ${endpoint.url} returned error in response body: ${JSON.stringify(responseBodyJson.error).substring(0, 200)}...`);

return {
success: false,
error: {
status: response.status,
body: responseBodyText
}
};
}

console.log(`[${networkSlug}] <-- Success from ${endpoint.url} (Status: ${response.status}, Response: ${responseBodyText.substring(0, 200)}${responseBodyText.length > 200 ? '...' : ''})`);

return {
success: true,
response: new Response(JSON.stringify(responseBodyJson), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
};
} else {
console.warn(`[${networkSlug}] Failed RPC ${endpoint.url}: Status ${response.status}, Body: ${responseBodyText.substring(0, 100)}...`);

return {
success: false,
error: {
status: response.status,
body: responseBodyText
}
};
}
} catch (error) {
clearTimeout(timeoutId);
const errorMessage = error instanceof Error ? error.message : String(error);

if (error instanceof Error && error.name === 'AbortError') {
console.warn(`[${networkSlug}] Failed RPC ${endpoint.url}: Timeout after ${RPC_TIMEOUT_MS}ms`);

return {
success: false,
error: {
status: 504,
body: `Gateway Timeout: Upstream endpoint ${endpoint.url} did not respond in time.`
}
};
} else {
console.warn(`[${networkSlug}] Failed RPC ${endpoint.url}: Network/Fetch error: ${errorMessage}`);

return {
success: false,
error: {
status: 502,
body: `Bad Gateway: Could not connect to upstream endpoint ${endpoint.url}.`
}
};
}
}
}

/**
* Attempts to forward a request to multiple RPC endpoints with fallback behavior.
* Returns the first successful response, or the last error if all endpoints fail.
*
* @param endpoints Array of RPC endpoints to try
* @param requestBody The JSON-RPC request body to forward
* @param networkSlug The network identifier for logging
* @returns The final response to return to the client
*/
export async function forwardWithFallback(
endpoints: RpcEndpoint[],
requestBody: any,
networkSlug: string
): Promise<Response> {
let lastError: { status: number; body: string } | null = null;

for (const endpoint of endpoints) {
const result = await forwardToRpcEndpoint(endpoint, requestBody, networkSlug);

if (result.success && result.response) {
return result.response;
} else if (result.error) {
lastError = result.error;
}
}

if (lastError) {
console.error(`[${networkSlug}] <-- All upstream RPCs failed. Returning last recorded error (Status: ${lastError.status}).`);

return new Response(lastError.body, {
status: lastError.status,
headers: { 'Content-Type': 'application/json' }
});
}

// Fallback in the unlikely event that the endpoints list was empty and the loop never ran.
console.error(`[${networkSlug}] <-- All upstream RPCs failed (no endpoints attempted).`);
return new Response(`Bad Gateway: No configured RPC endpoints for network '${networkSlug}' could be reached.`, { status: 502 });
}
Loading