Skip to content

How exposeFunction Works ​

Deep dive into the internals of Piggy's RPC (Remote Procedure Call) system. Understanding how browser β†’ Node.js communication works under the hood.


Overview ​

exposeFunction allows browser JavaScript to call Node.js functions. Here's the complete flow:

Browser JavaScript              C++ Layer              Node.js Library
─────────────────              ─────────              ──────────────

window.saveData({...})             β”‚                         β”‚
       β”‚                           β”‚                         β”‚
       β–Ό                           β”‚                         β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                   β”‚                         β”‚
β”‚  Queue Call  β”‚                   β”‚                         β”‚
β”‚  with args   β”‚                   β”‚                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚                         β”‚
       β”‚                           β”‚                         β”‚
       β–Ό                           β”‚                         β”‚
   [await] Promise                  β”‚                         β”‚
    pending...                      β”‚                         β”‚
                                    β”‚                         β”‚
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”‚
                            β”‚ Poll Queue    β”‚                 β”‚
                            β”‚ (250ms timer) β”‚                 β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚
                                    β”‚                         β”‚
                                    β–Ό                         β”‚
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”‚
                            β”‚ Send via      β”‚                 β”‚
                            β”‚ Socket        β”‚                 β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚
                                    β”‚                         β”‚
                                    β”‚   {cmd: "exposed.call",  β”‚
                                    β”‚    name: "saveData",     β”‚
                                    β”‚    data: {...}}         β”‚
                                    β–Ό                         β”‚
                                    β”‚ ──────────────────────► β”‚
                                    β”‚                         β”‚
                                    β”‚                    β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
                                    β”‚                    β”‚ Handlerβ”‚
                                    β”‚                    β”‚  runs  β”‚
                                    β”‚                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
                                    β”‚                         β”‚
                                    β”‚   {result: {...}}       β”‚
                                    β”‚ ◄────────────────────── β”‚
                                    β”‚                         β”‚
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                 β”‚
                            β”‚ Send result   β”‚                 β”‚
                            β”‚ to browser    β”‚                 β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β”‚
                                    β”‚                         β”‚
                                    β–Ό                         β”‚
       Promise resolves β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       with result

Layer 1: Browser Injection ​

When you call site.exposeFunction("saveData", handler), Piggy injects a stub into the browser:

javascript
// Injected JavaScript (simplified)
window.saveData = function(data) {
    return new Promise((resolve, reject) => {
        const callId = crypto.randomUUID();
        
        // Store promise callbacks
        window.__pendingCalls[callId] = { resolve, reject };
        
        // Queue the call
        window.__callQueue.push({
            name: "saveData",
            callId: callId,
            data: data,
            timestamp: Date.now()
        });
        
        // Trigger C++ poll (sets a flag)
        window.__triggerPoll();
    });
};

Injection Timing ​

The script is injected at DocumentCreation phase using QWebEngineScript:

cpp
// C++ side injection
QWebEngineScript script;
script.setInjectionPoint(QWebEngineScript::DocumentCreation);
script.setWorldId(QWebEngineScript::MainWorld);
script.setRunsOnSubFrames(true);
script.setSourceCode(injectionScript);

This ensures the stub exists before any page JavaScript runs.


Layer 2: Queue Management ​

Browser calls are queued to avoid overwhelming the socket:

javascript
// Queue implementation (simplified)
window.__callQueue = [];
window.__pendingCalls = {};
window.__pollFlag = false;

window.__triggerPoll = function() {
    if (!window.__pollFlag) {
        window.__pollFlag = true;
        // Sets a flag that C++ checks on its timer
    }
};

Queue Structure ​

json
{
  "callId": "550e8400-e29b-41d4-a716-446655440000",
  "name": "saveData",
  "data": { "username": "john", "score": 1000 },
  "timestamp": 1700000000000
}

Layer 3: C++ Polling ​

The C++ layer polls the queue every 250ms:

cpp
// C++ side polling (simplified)
void BrowserBridge::startPolling() {
    m_pollTimer = new QTimer(this);
    connect(m_pollTimer, &QTimer::timeout, this, &BrowserBridge::pollQueue);
    m_pollTimer->start(250); // 250ms interval
}

void BrowserBridge::pollQueue() {
    if (!m_hasPendingCalls) return;
    
    // Run JavaScript to get queued calls
    m_page->runJavaScript(R"(
        (function() {
            const queue = window.__callQueue;
            window.__callQueue = [];
            return JSON.stringify(queue);
        })();
    )", [this](const QVariant& result) {
        processQueue(result.toString());
    });
}

Why 250ms? ​

ConsiderationValue
Latency250ms max delay (acceptable for most scraping)
CPU usageLow (4 polls per second)
BatchingMultiple calls can be batched together

Layer 4: Socket Communication ​

Processed calls are sent over the socket:

cpp
void BrowserBridge::processQueue(const QString& queueJson) {
    QJsonArray calls = QJsonDocument::fromJson(queueJson.toUtf8()).array();
    
    for (const QJsonValue& call : calls) {
        QJsonObject obj = call.toObject();
        
        // Send to Node.js via socket
        QJsonObject message;
        message["type"] = "exposed_call";
        message["name"] = obj["name"].toString();
        message["callId"] = obj["callId"].toString();
        message["data"] = obj["data"];
        
        m_socket->sendText(QString::fromUtf8(
            QJsonDocument(message).toJson(QJsonDocument::Compact)
        ));
    }
}

Socket Message Format ​

json
{
  "type": "exposed_call",
  "name": "saveData",
  "callId": "550e8400-e29b-41d4-a716-446655440000",
  "data": { "username": "john", "score": 1000 }
}

Layer 5: Node.js Handler ​

The Node.js library receives and processes calls:

typescript
// Piggy client (simplified)
class PiggyClient {
    private eventHandlers = new Map();
    
    private handleEvent(event: any) {
        if (event.type === "exposed_call") {
            const { name, callId, data } = event;
            const handler = this.eventHandlers.get(name);
            
            if (handler) {
                // Execute handler
                Promise.resolve(handler(data))
                    .then(result => {
                        this.sendResult(callId, result, false);
                    })
                    .catch(error => {
                        this.sendResult(callId, error.message, true);
                    });
            }
        }
    }
    
    private sendResult(callId: string, result: any, isError: boolean) {
        this.send("exposed.result", {
            callId,
            result: isError ? result : JSON.stringify(result),
            isError
        });
    }
}

Layer 6: Result Return ​

Results are sent back through the same path:

cpp
// C++ receives result
void BrowserBridge::onResultReceived(const QString& message) {
    QJsonObject obj = QJsonDocument::fromJson(message.toUtf8()).object();
    
    QString callId = obj["callId"].toString();
    QString result = obj["result"].toString();
    bool isError = obj["isError"].toBool();
    
    // Send result back to browser
    QString js = QString(R"(
        (function() {
            const pending = window.__pendingCalls['%1'];
            if (pending) {
                delete window.__pendingCalls['%1'];
                if (%2) {
                    pending.reject(new Error('%3'));
                } else {
                    pending.resolve(JSON.parse('%3'));
                }
            }
        })();
    )").arg(callId).arg(isError).arg(result);
    
    m_page->runJavaScript(js);
}

Complete Call Timeline ​

StepTime (ms)ComponentAction
10Browserwindow.saveData() called
20.001BrowserCall queued, Promise pending
30-250BrowserWaiting for poll
4250C++Poll timer fires
5251C++Queue retrieved via JS
6252C++Message sent over socket
7253Node.jsMessage received
8254Node.jsHandler executes
9255Node.jsResult sent back
10256C++Result received
11257C++Result injected via JS
12258BrowserPromise resolves
Total~258msEnd-to-end latency

Performance Characteristics ​

Latency Breakdown ​

ComponentTime
Queue wait (average)~125ms
C++ polling overhead~2ms
Socket transmission~1ms
Node.js handlerVariable
Result return~3ms
Average total~150-300ms

Throughput ​

ScenarioCalls/sec
Small payloads (<1KB)~20-30
Large payloads (1MB)~3-5
Concurrent calls~15-20

Memory Management ​

Queue Cleanup ​

javascript
// Automatic cleanup of stale promises
setInterval(() => {
    const now = Date.now();
    const timeout = 30000; // 30 seconds
    
    for (const [callId, pending] of Object.entries(window.__pendingCalls)) {
        if (now - pending.timestamp > timeout) {
            pending.reject(new Error("Call timeout"));
            delete window.__pendingCalls[callId];
        }
    }
}, 5000);

Queue Size Limits ​

cpp
// C++ side limit
const int MAX_QUEUE_SIZE = 1000;

void BrowserBridge::processQueue() {
    if (queueSize() > MAX_QUEUE_SIZE) {
        // Clear old calls
        clearHalfQueue();
        m_page->runJavaScript("window.__callQueue = window.__callQueue.slice(-500);");
    }
}

Security Considerations ​

Call Validation ​

cpp
// Validate call origin
bool BrowserBridge::validateCall(const QString& name) {
    // Only calls from main frame (not iframes)
    if (!m_isMainFrame) return false;
    
    // Check against allowed functions
    return m_allowedFunctions.contains(name);
}

Data Sanitization ​

cpp
// Limit payload size
const int MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB

bool BrowserBridge::validatePayload(const QJsonObject& data) {
    QString json = QJsonDocument(data).toJson(QJsonDocument::Compact);
    return json.size() <= MAX_PAYLOAD_SIZE;
}

Debugging ​

Enable Trace Logging ​

cpp
// C++ debug output
void BrowserBridge::logCall(const QString& name, const QJsonObject& data) {
    if (m_debugEnabled) {
        qDebug() << "[exposeFunction] Call:" << name;
        qDebug() << "[exposeFunction] Data:" << data;
        qDebug() << "[exposeFunction] Queue size:" << m_queue.size();
    }
}

Browser Console Monitoring ​

javascript
// Monitor queue in browser console
setInterval(() => {
    console.log({
        queueSize: window.__callQueue?.length || 0,
        pendingCalls: Object.keys(window.__pendingCalls || {}).length
    });
}, 1000);

Common Issues ​

1. Function Not Found ​

Error: TypeError: window.myFunction is not a function

Cause: exposeFunction not called before navigation or function name mismatch.

Solution: Call exposeFunction before navigate().

2. Call Timeout ​

Error: Error: Call timeout

Cause: Node.js handler took too long (>30 seconds).

Solution: Increase timeout or optimize handler.

3. Queue Backlog ​

Symptom: Calls getting slower over time

Cause: Handler slower than call rate.

Solution: Batch calls or use exposeAndInject to reduce frequency.


Summary ​

The exposeFunction RPC system is built on:

  1. JavaScript stubs injected at DocumentCreation
  2. Queue mechanism to batch calls
  3. C++ polling every 250ms
  4. Socket communication for transport
  5. Promise-based API for async handling

This design prioritizes reliability and simplicity over raw speed, making it perfect for scraping and automation tasks.


Next Steps ​


Nothing Ecosystem Β· Ernest Tech House Β· Kenya Β· 2026

MIT Licensed | Built by Ernest Tech House Β· Kenya Β· 2026