How the Request Relay Works
Every KSExcel / KSWord method call your LWC makes travels through five layers before data comes back. Understanding this path helps you reason about latency, error handling, and why Office JS operations must happen in the taskpane rather than the server.
The example below traces ksGetSelectedRange(), but the same path applies to every method in the KSExcel and KSWord base classes.
End-to-end flow
LWC (Salesforce dialog)
└─ ksGetSelectedRange() [KSExcel base class — api.js]
└─ fetch POST /api/excel/get-selected-range [HTTPS, Bearer token]
└─ relay('ks:get-selected-range') [Express — excel.js]
└─ relayToOffice(sessionId, event) [relay.js]
│ stores Promise in Map keyed by requestId
└─ socket.emit → taskpane [socket.io]
└─ Excel.run → context.sync() [Office JS]
└─ socket.emit ks:response
└─ pendingRequests.resolve()
└─ res.json(result)
└─ fetch resolves
└─ ksGetSelectedRange() returns
Layer 1 — LWC calls the base class method
// In your component
const { address, values, rowCount, columnCount } = await this.ksGetSelectedRange();
ksGetSelectedRange() is defined in the KSExcel base class:
async ksGetSelectedRange() {
return this.ksCall('/api/excel/get-selected-range', {});
}
ksCall performs a standard fetch POST to https://app.keelstone.dev:
fetch('https://app.keelstone.dev/api/excel/get-selected-range', {
method: 'POST',
headers: { Authorization: `Bearer ${this.keelstoneSessionId}` },
body: JSON.stringify({})
})
The keelstoneSessionId is a JWT that identifies the active Keelstone session. It is passed into your component as an @api property from the Flow.
Layer 2 — Express server receives the request
The server has a route registered in excel.js:
router.post('/get-selected-range', relay('ks:get-selected-range'));
The requireSession middleware upstream validates the JWT and sets req.sessionId. The relay() factory then calls:
relayToOffice(req.sessionId, 'ks:get-selected-range', {})
The Express handler is now suspended, awaiting the Promise returned by relayToOffice. The HTTP connection stays open.
Layer 3 — relay.js bridges HTTP to socket.io
relayToOffice does two things:
1. Looks up the taskpane's socket ID from the database:
SELECT taskpane_socket_id FROM keelstone_sessions WHERE id = $1
When the Excel taskpane first connects, it emits ks:register with its sessionId and role: 'taskpane'. The server stores the resulting socket.id on the session row.
2. Creates a pending Promise keyed by a UUID:
const requestId = randomUUID();
pendingRequests.set(requestId, { resolve, reject, timer });
_io.to(socketId).emit('ks:get-selected-range', { requestId });
A 2-minute timeout is armed. The requestId is what threads the response back to the correct waiting Promise — there can be many in-flight requests simultaneously, each waiting independently.
Layer 4 — taskpane executes the Office JS call
The taskpane (running inside Excel's web view) has a socket listener for every supported event:
ksSocket.on('ks:get-selected-range', async ({ requestId }) => {
let result;
await Excel.run(async (context) => {
const range = context.workbook.getSelectedRange();
range.load(['address', 'values', 'rowCount', 'columnCount']);
await context.sync(); // ← actual Office JS API call to Excel
result = {
address: range.address.replace(/^[^[]*\[.*?\]/, ''), // strip [Book1] prefix
values: range.values, // 2D array
rowCount: range.rowCount,
columnCount: range.columnCount,
};
});
ksSocket.emit('ks:response', { requestId, result });
});
context.sync() is the only point where Office JS actually reads from Excel. Everything before it just queues property load requests. The Office JS API is only available here — in the taskpane — which is why reads and writes cannot happen on the server.
Layer 5 — response unwinds the stack
The taskpane emits ks:response with the same requestId. Back in relay.js:
socket.on('ks:response', ({ requestId, result, error }) => {
const pending = pendingRequests.get(requestId);
clearTimeout(pending.timer);
pendingRequests.delete(requestId);
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(result);
}
});
Resolving the Promise unblocks the Express handler, which calls res.json(result). That resolves the fetch() in api.js, which resolves ksGetSelectedRange(), returning { address, values, rowCount, columnCount } to your LWC.
Error handling
Errors can originate at any layer:
| Layer | Error | What the LWC sees |
|---|---|---|
| Server auth | Invalid/expired session token | fetch resolves with 401; ksCall throws |
| relay.js | Taskpane not connected | 503 thrown from relayToOffice |
| relay.js | No response in 2 minutes | 504 timeout; Promise rejects |
| Taskpane | Office JS exception | ks:response sent with error string; Promise rejects with 502 |
All of these surface as a thrown exception from await this.ksGetSelectedRange(), so a standard try/catch in your component handles every case.
Why this architecture
- Access tokens stay server-side. The LWC never sees the Salesforce or Office credentials — only the session token.
- Office JS stays in the taskpane. The Excel / Word APIs are only available inside the Office web view. The server cannot call them directly.
- The session ID is the routing key. One server can handle many users simultaneously; the
taskpane_socket_idon the session row is what routes each relay to the correct open document.