diff --git a/apps/client-standalone/src/lightweight/cls_provider.ts b/apps/client-standalone/src/lightweight/cls_provider.ts index 0ef9a35c95..08d1a24d7b 100644 --- a/apps/client-standalone/src/lightweight/cls_provider.ts +++ b/apps/client-standalone/src/lightweight/cls_provider.ts @@ -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 = new Map(); + private contextStack: Map[] = []; + private cleanupTimers = new WeakMap, ReturnType>(); + private readonly CLEANUP_GRACE_PERIOD = 1000; // 1 second for fire-and-forget operations + + private getCurrentContext(): Map { + if (this.contextStack.length === 0) { + throw new Error("ExecutionContext not initialized"); + } + return this.contextStack[this.contextStack.length - 1]; + } get(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(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(); + 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; + 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): 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); } }