Skip to content
Open
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ dist/
*.mjs
!tsconfig.json

# Rust
target/
Cargo.lock

# Lock files
pnpm-lock.yaml
package-lock.json

# IDE
.idea/
.vscode/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ iii --version
| [api-frameworks-workers](examples/api-frameworks-workers) | Run 4 API frameworks as separate workers |
| [api-frameworks-auto-register](examples/api-frameworks-auto-register) | Auto-registration + shared context (logging, state, request tracking) |
| [iii-vs-traditional](examples/iii-vs-traditional) | Side-by-side: connect a Python service with iii (~120 lines) vs traditional gateway (~465 lines) |
| [polyglot-coordination](examples/polyglot-coordination) | Coordinate Python, Node.js, and Rust services (Python via stdin/stdout IPC, Rust via HTTP) |

## Quick Start

Expand Down
11 changes: 11 additions & 0 deletions examples/polyglot-coordination/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules/
dist/
*.js
*.d.ts
.DS_Store
.env
.env.local
__pycache__/
*.pyc
target/
Cargo.lock
201 changes: 201 additions & 0 deletions examples/polyglot-coordination/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# Polyglot Coordination Example

This example demonstrates **iii-engine** seamlessly coordinating services across **Python, Node.js, and Rust** without requiring HTTP endpoints for all services.

**Key differentiator**: The Python service communicates via **stdin/stdout IPC** (not HTTP), showing that iii abstracts away transport mechanisms entirely.

## Architecture

```mermaid
flowchart TB
subgraph Client
API[curl / HTTP Client]
end

subgraph iii[iii-engine]
ENGINE[WebSocket Hub<br/>ws://127.0.0.1:49134]
end

subgraph Workers[Node.js Workers]
USER[User Service<br/>users.*]
DATA[Data Requester<br/>analytics.*]
STRIPE_BRIDGE[Stripe Bridge<br/>stripe.*]
end

subgraph Services[External Services]
PYTHON[Python Analytics<br/>stdin/stdout IPC<br/>NO HTTP]
RUST[Rust Fake Stripe<br/>HTTP :4040]
end

API -->|POST /onboard| ENGINE
ENGINE <-->|WebSocket| USER
ENGINE <-->|WebSocket| DATA
ENGINE <-->|WebSocket| STRIPE_BRIDGE
DATA -->|spawns & IPC| PYTHON
STRIPE_BRIDGE -->|HTTP calls| RUST

style PYTHON fill:#3776ab,color:#fff
style RUST fill:#dea584,color:#000
style ENGINE fill:#6366f1,color:#fff
style USER fill:#22c55e,color:#fff
style DATA fill:#22c55e,color:#fff
style STRIPE_BRIDGE fill:#22c55e,color:#fff
```

## Business Scenario: SaaS User Onboarding

A realistic workflow tying all 3 languages together:

1. **New user signs up** → Node.js User Service
2. **Create Stripe customer + subscription** → Rust Fake Stripe (via HTTP, wrapped by iii)
3. **Run onboarding analytics** → Python Analytics (via stdin/stdout IPC)
4. **Store user profile with risk score** → Orchestrated by Node.js Workflow

## Prerequisites

- **Node.js 18+** or **Bun** (for workers)
- **Python 3.x** (stdlib only, no pip install needed)
- **Rust toolchain** (for Axum server)
- **iii-engine** running (`iii` command)

## Quick Start

### Terminal 1: Start iii-engine
```bash
iii
```

### Terminal 2: Start Rust Fake Stripe server
```bash
cd services/rust-stripe
cargo run --release
```

### Terminal 3: Install deps and start workers
```bash
pnpm install
pnpm dev
```

## Testing

### Full onboarding flow (coordinates all 3 languages)
```bash
curl -X POST http://localhost:3111/onboard \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "name": "Alice Smith", "plan": "pro"}'
```

Expected response:
```json
{
"message": "User onboarded successfully",
"traceId": "abc123...",
"user": {
"id": "usr_...",
"email": "alice@example.com",
"name": "Alice Smith",
"plan": "pro",
"stripeCustomerId": "cus_...",
"subscriptionId": "sub_...",
"riskScore": 45.5
},
"stripeCustomer": { "id": "cus_...", "email": "...", "name": "..." },
"subscription": { "id": "sub_...", "status": "active", "plan": "pro" },
"analytics": { "riskScore": 45.5, "factors": ["custom_domain", "full_name_provided"] }
}
```

### Get metrics (calls Python analytics)
```bash
curl http://localhost:3111/onboard/metrics
```

### Onboard multiple users and check metrics
```bash
curl -X POST http://localhost:3111/onboard \
-d '{"email": "bob@gmail.com", "name": "Bob", "plan": "free"}' \
-H "Content-Type: application/json"

curl -X POST http://localhost:3111/onboard \
-d '{"email": "carol@stanford.edu", "name": "Carol Chen", "plan": "enterprise"}' \
-H "Content-Type: application/json"

curl http://localhost:3111/onboard/metrics
```

## Key Insight

All function calls look identical regardless of transport:

```typescript
// Calls Rust via HTTP (wrapped by stripe-bridge)
await bridge.invokeFunction('stripe.createCustomer', { email, name })

// Calls Python via stdin/stdout IPC (wrapped by data-requester)
await bridge.invokeFunction('analytics.score', { userId, email, name })

// Calls local Node.js function
await bridge.invokeFunction('users.create', { email, name })
```

**The caller doesn't know or care about the underlying transport. That's the power of iii.**

## File Structure

```
polyglot-coordination/
├── README.md
├── package.json
├── tsconfig.json
├── services/
│ ├── python-analytics/
│ │ └── analytics.py # stdin/stdout JSON-RPC (NO HTTP)
│ └── rust-stripe/
│ ├── Cargo.toml
│ └── src/main.rs # Axum HTTP server
├── workers/
│ ├── user-service.ts # User CRUD + billing
│ ├── data-requester.ts # Spawns Python, exposes analytics.*
│ └── stripe-bridge.ts # Wraps Rust HTTP as iii functions
├── workflow/
│ └── onboarding.ts # Orchestrates full signup flow
└── lib/
├── bridge.ts # Shared bridge factory
├── python-ipc.ts # Python subprocess manager
└── types.ts # Shared TypeScript types
```

## How It Works

### Python Analytics (stdin/stdout)
The Python service has **no HTTP endpoints**. It reads JSON from stdin, processes it, and writes JSON to stdout:

```python
for line in sys.stdin:
request = json.loads(line)
result = handle_request(request['method'], request['params'])
sys.stdout.write(json.dumps({'id': request['id'], 'result': result}) + '\n')
```

The `data-requester.ts` worker spawns this script and communicates via stdin/stdout, then exposes the functionality as iii functions.

### Rust Fake Stripe (HTTP)
A standard Axum HTTP server providing a Stripe-like API. The `stripe-bridge.ts` worker makes HTTP calls to it and exposes the endpoints as iii functions.

### Node.js User Service
Pure Node.js with in-memory storage, directly registered as iii functions.

## Why This Matters

Traditional microservice orchestration requires:
- Every service to expose HTTP/gRPC endpoints
- Service discovery (Consul, etcd, K8s DNS)
- Load balancers, ingress controllers
- API gateways for routing

With iii-engine:
- Services can use **any transport** (HTTP, IPC, stdin/stdout, sockets)
- iii handles routing via **function names**
- No service discovery needed - iii is the registry
- No API gateway - iii exposes triggers directly
14 changes: 14 additions & 0 deletions examples/polyglot-coordination/lib/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Bridge } from '@iii-dev/sdk'

const ENGINE_URL = process.env.III_ENGINE_URL ?? 'ws://127.0.0.1:49134'

export function createBridge(serviceName: string): Bridge {
return new Bridge(ENGINE_URL, {
otel: {
enabled: true,
serviceName,
metricsEnabled: true,
metricsExportIntervalMs: 5000,
},
})
}
135 changes: 135 additions & 0 deletions examples/polyglot-coordination/lib/python-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { spawn, type ChildProcess } from 'child_process'
import { createInterface, type Interface } from 'readline'
import { EventEmitter } from 'events'
import { existsSync } from 'fs'

interface PendingRequest {
resolve: (result: unknown) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}

const PYTHON_PATH = process.env.PYTHON_PATH ?? 'python3'

export class PythonIPC extends EventEmitter {
private process: ChildProcess | null = null
private readline: Interface | null = null
private requestId = 0
private pending = new Map<number, PendingRequest>()
private ready = false
private readyPromise: Promise<void>
private readyResolve!: () => void

constructor(
private scriptPath: string,
private timeoutMs = 30000,
private maxPendingRequests = 1000
) {
super()
this.readyPromise = new Promise((resolve) => {
this.readyResolve = resolve
})
}

async start(): Promise<void> {
if (!existsSync(this.scriptPath)) {
throw new Error(`Python script not found: ${this.scriptPath}`)
}

this.process = spawn(PYTHON_PATH, [this.scriptPath], {
stdio: ['pipe', 'pipe', 'pipe'],
})

this.readline = createInterface({
input: this.process.stdout!,
crlfDelay: Infinity,
})

this.readline.on('line', (line) => this.handleLine(line))

this.process.stderr?.on('data', (data) => {
const msg = data.toString().trim()
if (msg) console.error(`[Python stderr] ${msg}`)
})

this.process.on('close', (code) => {
this.ready = false
this.emit('close', code)
for (const [id, req] of this.pending) {
clearTimeout(req.timeout)
req.reject(new Error(`Python process exited with code ${code}`))
this.pending.delete(id)
}
})

this.process.on('error', (err) => {
this.emit('error', err)
})

await this.call('ping', {})
this.ready = true
this.readyResolve()
}

async waitReady(): Promise<void> {
return this.readyPromise
}

async call<T = unknown>(method: string, params: unknown): Promise<T> {
if (!this.process || !this.process.stdin) {
throw new Error('Python process not started')
}

if (this.pending.size >= this.maxPendingRequests) {
throw new Error(`Too many pending requests (max: ${this.maxPendingRequests})`)
}

const id = ++this.requestId
if (this.requestId > Number.MAX_SAFE_INTEGER - 1) {
this.requestId = 0
}
const request = JSON.stringify({ id, method, params })

return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`Request ${method} timed out after ${this.timeoutMs}ms`))
}, this.timeoutMs)

this.pending.set(id, { resolve: resolve as (r: unknown) => void, reject, timeout })
this.process!.stdin!.write(request + '\n')
})
}

private handleLine(line: string): void {
try {
const response = JSON.parse(line)
const { id, result, error } = response

const req = this.pending.get(id)
if (!req) return

clearTimeout(req.timeout)
this.pending.delete(id)

if (error) {
req.reject(new Error(error.message || error))
} else {
req.resolve(result)
}
} catch {
console.error(`[Python IPC] Failed to parse: ${line}`)
}
}

stop(): void {
if (this.process) {
this.process.kill()
this.process = null
}
if (this.readline) {
this.readline.close()
this.readline = null
}
}
}
Loading