What createWsRequestTracker Does
export function createWsRequestTracker<Epoch extends boolean = false>(
options: WsRequestTrackerOptions<Epoch>,
): WsRequestTracker<Epoch> {
// Creates a tracker that bridges WebSocket request/response pairs into Promises.
// Handles timeouts and provides a clean API for awaiting responses.
const { timeoutMs, enableEpochs = false } = options;
const pending = new Map<string, PendingEntry>();
// pending map to store in-flight WS requests, keyed by contractId
let epoch = 0;
// current epoch used for stale request handling (epoch mode)
return {
track<T = void>(
contractId: string,
): Promise<Epoch extends true ? T | null : T> {
// Start tracking a WS request by its contract ID
if (enableEpochs) {
epoch++;
}
const requestEpoch = epoch;
return new Promise<any>((resolve, reject) => {
const timeoutId = setTimeout(() => {
const entry = pending.get(contractId);
if (!entry) return; // already resolved/rejected
pending.delete(contractId);
if (enableEpochs && entry.epoch !== epoch) {
entry.resolve(null);
return;
}
let errorMessage =
`WS request ${contractId} timed out after ${timeoutMs}ms — server did not respond`;
reject(new WebError(errorMessage, "WsTimeoutError"));
}, timeoutMs);
pending.set(contractId, {
resolve: resolve as (value: unknown) => void,
reject,
timeoutId,
epoch: requestEpoch,
});
});
},
resolve(contractId: string, data?: unknown): void {
// Resolve pending request for contractId
const entry = pending.get(contractId);
if (!entry) return;
if (enableEpochs && entry.epoch !== epoch) {
clearTimeout(entry.timeoutId);
pending.delete(contractId);
entry.resolve(null);
return;
}
clearTimeout(entry.timeoutId);
pending.delete(contractId);
entry.resolve(data);
},
// Reject a pending request
reject(contractId: string, error: WebError): void {
const entry = pending.get(contractId);
if (!entry) return;
clearTimeout(entry.timeoutId);
pending.delete(contractId);
entry.reject(error);
},
// Bump epoch to stale-discard all in-flight resolves.
invalidate(): void {
if (!enableEpochs) return;
epoch++;
},
// Reject all pending and clear all timeouts.
dispose(): void {
for (const [contractId, entry] of pending) {
clearTimeout(entry.timeoutId);
// Reject with disposed error
let errMessage =
`WS tracker disposed while request ${contractId} was still pending`;
entry.reject(
new WebError(errMessage, "WsTrackerDisposedError"),
);
}
pending.clear();
},
// Check if request is pending
hasPending(contractId: string): boolean {
return pending.has(contractId);
},
// Get number of pending requests
pendingCount(): number {
return pending.size;
},
};createWsRequestTracker is a Promise-WS bridge that converts WebSocket
request/response pairs into synchronous-looking async operations. In a WebSocket
protocol where you send a packet and receive a response later via a callback
listener, this tracker provides a clean API to await the response as a Promise.
Core Problem It Solves
Without this tracker:
- You send a WS packet (fire-and-forget)
- Later, a listener callback receives the response
- How do you connect step 2 back to step 1? How do you handle timeouts? How do you ensure stale responses don’t hang your app?
The tracker solves this by:
• Creating a Promise when you call track(contractId) after sending a packet
• Storing it keyed by the contract ID (packet UUID)
• Resolving/rejecting it when the listener receives the response
• Handling timeouts with automatic rejection
Workflow (Complete Flow)
┌────────────────────────────────────────────────────────────────────────────┐
│ 1. Component calls readFile() or saveFile() │
│ (e.g., user clicks a file or presses Ctrl+S) │
└────────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────────┐
│ 2. EditorStore sends WS packet via readFileWsFn / saveFileWsFn │
│ → Returns a contractId (UUID) │
└────────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────────┐
│ 3. tracker.track(contractId) creates a Promise & stores it in pending Map │
│ → Returns Promise<T | null> (T=void by default, or T=fileData) │
└────────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────────┐
│ 4. User waits for server response (await track()) │
└────────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────────┐
│ 5. Server responds via WebSocket │
│ → Listener (editorWebsocketEventHandler) receives packet │
│ → Decodes the contract │
└────────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────────┐
│ 6. applyFileRead / applyFileWritten calls tracker.resolve() │
│ → Looks up contractId in pending Map │
│ → Clears timeout │
│ → Calls resolve() callback with data │
└────────────────────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────────────────────┐
│ 7. await track() resolves, EditorStore returns typed result │
│ → { kind: 'success', fileData } │
│ → Or { kind: 'stale' } if epochs enabled and a newer request came in │
└────────────────────────────────────────────────────────────────────────────┘Why We Need It for service-cad/frontend/web
- Type-Safe Async/Await Over WebSocket Protocol
The WebSocket protocol is event-driven (fire-and-forget + callback), but Svelte components work better with await:
// Without tracker: messy callback style
sendReadFile(path, (result) => {
if (result.error) { /* handle */ }
if (result.data) { /* handle */ }
});
// With tracker: clean async/await
const response = await readFile(path);
// Throws on error, returns data on success
-
Timeout Handling The tracker implements automatic timeouts via setTimeout. If the server doesn’t respond within WEBSOCKET_TIMEOUT_MS, the promise rejects with a WsTimeoutError.
-
Epoch Tracking (Last-Write-Wins) With enableEpochs: true: • User rapidly clicks between files → each click sends a request • Only the latest file’s response matters (the others are “stale”) • Stale responses resolve with null instead of hanging or updating stale UI • The caller checks if (response === null) return { kind: ‘stale’ }
-
Cleanup & Memory Management • dispose() rejects all pending promises and clears timeouts • Prevents memory leaks when components unmount • Used in Svelte onDestroy hooks
Two Modes of Operation
Simple Mode (default, enableEpochs: false)
• Every request gets its own independent promise
• Used by loadProjectTracker and killSessionTracker
• Example: loadProjectTracker.track(‘xyz’) → always resolves when server responds
Epoch Mode (enableEpochs: true)
• Each track() increments a monotonic epoch counter
• When resolve() is called, it checks if the entry’s epoch matches current
• Stale entries (older epoch) resolve with null instead of data
• Used by readTracker, saveTracker, fileTreeTracker, compileTracker
Key Methods
| Method | Description |
|---|---|
track(contractId) | Start tracking, returns `Promise<T |
resolve(contractId, data?) | Resolve pending request (called by listener) |
reject(contractId, error) | Reject on error (always surfaces, never stale-discard) |
invalidate() | Bump epoch, mark all pending entries as stale |
dispose() | Reject all pending + clear timeouts (component cleanup) |
hasPending(contractId) | Check if entry exists (used by listeners) |
pendingCount() | Number of unprocessed requests |
Files Using This Tracker
• codeEditor/wsHooks/tracker.ts: readTracker, saveTracker (epoch mode)
• fileTree/wsHooks/tracker.ts: fileTreeTracker (epoch mode)
• canvas/wsHooks/tracker.ts: compileTracker (default mode)
• gitEditor/wsHooks/tracker.ts: gitTracker (epoch mode)
• core/pageState/editorPage/tracker.ts: loadProjectTracker, killSessionTracker (default mode)
All use WEBSOCKET_TIMEOUT_MS (configured elsewhere) for timeout duration.
import { WebError } from "@core/utils/errors/webError";
/**
* Internal bookkeeping for a single in-flight WS request.
*
* `resolve` accepts `unknown` rather than a generic `T` because the pending
* map is type-erased — it stores entries for many different request types
* simultaneously, so there's no single `T` that fits all entries. The
* per-call type safety lives on the public `track<T>()` method instead.
*
* We use `unknown` over `any` deliberately: `unknown` forces a type
* assertion at the one internal call site where we store the resolve
* callback (`resolve as (value: unknown) => void`), making the type
* erasure explicit and auditable. With `any`, TypeScript stops checking
* entirely — if we accidentally passed the wrong value internally, the
* compiler wouldn't catch it. The single assertion is worth the trade-off
* for keeping the rest of the internals type-checked.
*/
export type PendingEntry = {
resolve: (value: unknown) => void;
reject: (error: WebError) => void;
timeoutId: ReturnType<typeof setTimeout>;
epoch: number;
};
/**
* Generic over Epoch — controls the return type of track<T>().
*
* When Epoch is false (default), track<T>() returns Promise<T>.
* When Epoch is true, track<T>() returns Promise<T | null> because
* stale responses resolve with null instead of hanging forever.
*
* TypeScript infers Epoch from the enableEpochs property value:
* createWsRequestTracker({ timeoutMs: WEBSOCKET_TIMEOUT_MS, enableEpochs: true })
* // → WsRequestTracker<true>, track returns Promise<T | null>
*
* createWsRequestTracker({ timeoutMs: WEBSOCKET_TIMEOUT_MS })
* // → WsRequestTracker<false>, track returns Promise<T>
*/
export type WsRequestTrackerOptions<Epoch extends boolean = false> = {
timeoutMs: number;
enableEpochs?: Epoch;
};
export type WsRequestTracker<Epoch extends boolean = false> = {
/**
* Start tracking a request. Returns a typed Promise that resolves
* when resolve() is called with the matching contract ID.
*
* T defaults to void — callers that expect response data specify it:
* await tracker.track<string[]>(contractId); // expects string[]
* await tracker.track(contractId); // expects void
*
* With enableEpochs: true, returns Promise<T | null>. null indicates
* a stale response — a newer track() call superseded this one. The
* caller checks for null to detect staleness and return early.
*/
track<T = void>(
contractId: string,
): Promise<Epoch extends true ? T | null : T>;
/**
* Resolve a pending request with optional data.
*
* Accepts `unknown` because the tracker is type-erased internally —
* it doesn't know which `T` the corresponding track<T>() call used.
* The caller (WS listener) passes whatever the server sent, and the
* awaiting handler receives it typed via the Promise<T> from track().
*
* With enableEpochs: true, stale entries (epoch doesn't match current)
* are settled with null instead of the provided data. This ensures the
* caller's promise always settles — its finally block runs, no leaked
* closures.
*/
resolve(contractId: string, data?: unknown): void;
/** Reject a pending request with an error. Always surfaces (never stale-discarded). */
reject(contractId: string, error: WebError): void;
/** Bump epoch to stale-discard all in-flight resolves. No-op without enableEpochs. */
invalidate(): void;
/** Reject all pending and clear timers. Call on component destroy. */
dispose(): void;
/** Whether a tracked entry exists for this contract ID. */
hasPending(contractId: string): boolean;
/** Number of currently pending (unresolved) tracked requests. */
pendingCount(): number;
};
/**
* Creates a tracker that bridges WebSocket request/response pairs into Promises.
*
* The problem: WS senders fire-and-forget a packet, and the response arrives
* later via a separate listener callback. There's no built-in way for the
* caller to await the full round-trip, detect timeouts, or correlate a
* response back to the request that triggered it.
*
* This tracker solves that by letting the caller `track(contractId)` after
* sending a packet. This returns a Promise that resolves when the listener
* calls `resolve(contractId)` with the matching ID, or rejects on timeout.
*
* Two modes of operation:
*
* 1. Simple tracking (enableEpochs=false, the default)
* Every tracked request gets its own independent promise. resolve() always
* delivers. This is the mode used by the git editor where each operation
* (commit, stage, merge) is a distinct action that doesn't supersede others.
*
* 2. Epoch tracking (enableEpochs=true)
* Each track() call increments a monotonic epoch counter. When resolve()
* is called, it checks whether the pending entry's epoch matches the
* current epoch. If stale (a newer request was tracked since), the entry
* is settled with null — the awaiting promise resolves to null, signaling
* staleness to the caller. This implements last-write-wins for rapid
* sequential requests (e.g. clicking between files quickly — only the
* most recent file read matters).
*
* Stale timeouts also settle with null (not rejection) — if a stale
* request's timeout fires, it resolves with null rather than surfacing
* an error for a request the user already navigated away from.
*
* reject() always surfaces regardless of epoch — errors should never be
* silently swallowed.
*
* invalidate() bumps the epoch without tracking a new request, causing all
* in-flight responses to be discarded when they arrive. Use this when you
* want to cancel all pending without triggering error callbacks (e.g.
* navigating away from the current context).
*/
export function createWsRequestTracker<Epoch extends boolean = false>(
options: WsRequestTrackerOptions<Epoch>,
): WsRequestTracker<Epoch> {
const { timeoutMs, enableEpochs = false } = options;
const pending = new Map<string, PendingEntry>();
let epoch = 0;
return {
/**
* Start tracking a WS request by its contract ID (the packet UUID).
* Returns a Promise that resolves when the listener calls resolve()
* with the same contract ID, or rejects on timeout.
*
* Call this immediately after sending the packet:
* const contractId = await sender(...);
* await tracker.track(contractId);
*
* This creates a `Promise<T>` with a properly typed `resolve: (value: T) => void`
* callback but then immediately erases it to `unknown` for storage. When the
* promise resolves, the caller gets T back. The erasure is a one-line bridge
* between the typed public API and the untyped internal map. This is us making T
* the same type as our storage, not the typescript rule that `unknown` types must
* do a type assertion before use.
*/
track<T = void>(
contractId: string,
): Promise<Epoch extends true ? T | null : T> {
if (enableEpochs) {
epoch++;
}
// Snapshot the current epoch so we can check staleness on resolve
const requestEpoch = epoch;
// Cast: TypeScript can't evaluate the conditional return type
// inside a generic factory. The runtime behavior is correct —
// with epochs, stale entries resolve with null; without, they
// resolve with T. The public type signature enforces this.
return new Promise<any>((resolve, reject) => {
const timeoutId = setTimeout(() => {
const entry = pending.get(contractId);
if (!entry) return; // already resolved/rejected
pending.delete(contractId);
// Stale epoch check — if a newer request was tracked since this
// one, don't surface a timeout error for an abandoned request.
// Resolve with null so the caller's promise settles cleanly
// (its finally block runs, no leaked closure).
if (enableEpochs && entry.epoch !== epoch) {
entry.resolve(null);
return;
}
let errorMessage =
`WS request ${contractId} timed out after ${timeoutMs}ms — server did not respond`;
reject(new WebError(errorMessage, "WsTimeoutError"));
}, timeoutMs);
pending.set(contractId, {
resolve: resolve as (value: unknown) => void,
reject,
timeoutId,
epoch: requestEpoch,
});
});
},
/**
* Resolve a pending request. Called by the WS listener after applying
* state from the server response.
*
* If epochs are enabled and the entry is stale (a newer request was
* tracked since this one), the entry is settled with null instead of
* the provided data. This ensures the caller's promise always settles
* — its finally block runs and closures are released. The caller
* checks for null to detect staleness.
*
* No-op if contractId is not found (already timed out, already
* resolved, or an unknown/broadcast ID).
*/
resolve(contractId: string, data?: unknown): void {
const entry = pending.get(contractId);
if (!entry) return;
// Stale epoch check — a newer request was tracked since this one.
// Settle with null so the caller's promise resolves (its finally
// block runs, no leaked closure). The caller checks for null to
// detect staleness: if (result === null) return { kind: 'stale' };
if (enableEpochs && entry.epoch !== epoch) {
clearTimeout(entry.timeoutId);
pending.delete(contractId);
entry.resolve(null);
return;
}
clearTimeout(entry.timeoutId);
pending.delete(contractId);
entry.resolve(data);
},
/**
* Reject a pending request. Called by the WS listener when applying
* state fails, or internally on timeout.
*
* Unlike resolve, reject always surfaces regardless of epoch staleness.
* Errors should never be silently swallowed — the handler needs to
* know something went wrong so it can surface it to the user.
*
* No-op if contractId is not found.
*/
reject(contractId: string, error: WebError): void {
const entry = pending.get(contractId);
if (!entry) return;
clearTimeout(entry.timeoutId);
pending.delete(contractId);
entry.reject(error);
},
/**
* Bump the epoch without tracking a new request.
*
* All currently pending entries become stale — their resolve() calls
* will settle with null when the server eventually responds.
* Their timeouts also settle with null (not rejection) since the
* timeout callback checks epoch staleness before rejecting.
*
* Use this when you want to "cancel" all in-flight requests without
* triggering error callbacks. For example, navigating away from a
* context where the responses are no longer relevant.
*
* No-op if epochs are not enabled.
*/
invalidate(): void {
if (!enableEpochs) return;
epoch++;
},
/**
* Reject all pending requests and clear all timeouts.
* Call this from component onDestroy to prevent memory leaks
* and dangling timer callbacks.
*/
dispose(): void {
for (const [contractId, entry] of pending) {
clearTimeout(entry.timeoutId);
// Reject with disposed error
let errMessage =
`WS tracker disposed while request ${contractId} was still pending`;
entry.reject(
new WebError(errMessage, "WsTrackerDisposedError"),
);
}
pending.clear();
},
/**
* Whether a tracked entry exists for this contract ID.
* Used by listeners to decide whether to apply state:
* - true: this is a response to a tracked request — apply + resolve
* - false: timed out, already resolved, or unknown (future broadcast)
*/
hasPending(contractId: string): boolean {
return pending.has(contractId);
},
/** Number of currently pending (unresolved) tracked requests. */
pendingCount(): number {
return pending.size;
},
};
}