chore(standalone): use CLS with per-request context isolation

This commit is contained in:
Elian Doran 2026-02-09 18:20:14 +02:00
parent 49ce312ab2
commit ad41a58904
No known key found for this signature in database

View File

@ -3,30 +3,75 @@ import { ExecutionContext } from "@triliumnext/core";
/**
* Browser execution context implementation.
*
* Unlike the server (which uses cls-hooked for per-request isolation),
* the browser is single-threaded with a single user and doesn't need
* request-level isolation. We maintain a single persistent context
* throughout the page lifetime.
* Handles per-request context isolation with support for fire-and-forget async operations
* using a context stack and grace-period cleanup to allow unawaited promises to complete.
*/
export default class BrowserExecutionContext implements ExecutionContext {
private store: Map<string, any> = new Map();
private contextStack: Map<string, any>[] = [];
private cleanupTimers = new WeakMap<Map<string, any>, ReturnType<typeof setTimeout>>();
private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations
private getCurrentContext(): Map<string, any> {
if (this.contextStack.length === 0) {
throw new Error("ExecutionContext not initialized");
}
return this.contextStack[this.contextStack.length - 1];
}
get<T = any>(key: string): T {
return this.store.get(key);
return this.getCurrentContext().get(key);
}
set(key: string, value: any): void {
this.store.set(key, value);
this.getCurrentContext().set(key, value);
}
reset(): void {
this.store.clear();
this.contextStack = [];
}
init<T>(callback: () => T): T {
// In browser, we don't need per-request isolation.
// Just execute the callback with the persistent context.
// This allows fire-and-forget operations to access context.
return callback();
const context = new Map<string, any>();
this.contextStack.push(context);
// Cancel any pending cleanup timer for this context
const existingTimer = this.cleanupTimers.get(context);
if (existingTimer) {
clearTimeout(existingTimer);
this.cleanupTimers.delete(context);
}
try {
const result = callback();
// If the result is a Promise
if (result && typeof result === 'object' && 'then' in result && 'catch' in result) {
const promise = result as unknown as Promise<any>;
return promise.finally(() => {
this.scheduleContextCleanup(context);
}) as T;
} else {
// For synchronous results, schedule delayed cleanup to allow fire-and-forget operations
this.scheduleContextCleanup(context);
return result;
}
} catch (error) {
// Always clean up on error with grace period
this.scheduleContextCleanup(context);
throw error;
}
}
private scheduleContextCleanup(context: Map<string, any>): void {
const timer = setTimeout(() => {
// Remove from stack if still present
const index = this.contextStack.indexOf(context);
if (index !== -1) {
this.contextStack.splice(index, 1);
}
this.cleanupTimers.delete(context);
}, this.CLEANUP_GRACE_PERIOD);
this.cleanupTimers.set(context, timer);
}
}