From ed05091768943ea8c98c48379b172d7b9e10ca1c Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 9 Feb 2026 04:11:10 +0100 Subject: [PATCH] fix: preserve AsyncLocalStorage context in esbuild plugin callbacks esbuild communicates with its Go binary via a child process. When plugin callbacks (onLoad) fire, they run in the child process's message handler context, which does NOT inherit the AsyncLocalStorage store from the original caller. This caused MultiProjectFSAdapter's getAdapter() to fail silently in proxy mode, resulting in "File not found" errors for all API route dependencies (e.g. lib/xml-parser.ts). Add wrapWithCurrentContext() utility that captures the current ALS store and re-enters it inside the callback, then wrap both the vf-adapter-resolve and import-map plugin onLoad callbacks. Fixes flow-ops.veryfront.com/api/flows returning 500 "Handler not found". --- .../fs/veryfront/multi-project-adapter.ts | 19 ++++ src/routing/api/module-loader/loader.ts | 88 +++++++++++-------- 2 files changed, 69 insertions(+), 38 deletions(-) diff --git a/src/platform/adapters/fs/veryfront/multi-project-adapter.ts b/src/platform/adapters/fs/veryfront/multi-project-adapter.ts index b1a184e9d7..81daddff91 100644 --- a/src/platform/adapters/fs/veryfront/multi-project-adapter.ts +++ b/src/platform/adapters/fs/veryfront/multi-project-adapter.ts @@ -270,6 +270,25 @@ export function getCurrentRequestContext(): RequestContext | null { return asyncLocalStorage.getStore() ?? null; } +/** + * Wraps a callback to preserve the current AsyncLocalStorage context. + * + * esbuild communicates with its Go binary via a child process. When esbuild's + * plugin callbacks (onResolve, onLoad) fire, they run in the child process's + * message handler context, which does NOT inherit the AsyncLocalStorage store + * from the original caller. This utility captures the current store and + * re-enters it inside the callback, so that `getAdapter()` can resolve the + * correct per-project adapter. + */ +export function wrapWithCurrentContext unknown>(fn: T): T { + const store = asyncLocalStorage.getStore(); + if (!store) return fn; + + return ((...args: Parameters) => { + return asyncLocalStorage.run(store, () => fn(...args)); + }) as unknown as T; +} + export function getRequestScopedFile(cacheKey: string): string | undefined { return asyncLocalStorage.getStore()?.fileCache?.get(cacheKey); } diff --git a/src/routing/api/module-loader/loader.ts b/src/routing/api/module-loader/loader.ts index b9c2eab1bb..72e549ba86 100644 --- a/src/routing/api/module-loader/loader.ts +++ b/src/routing/api/module-loader/loader.ts @@ -14,6 +14,7 @@ import * as pathHelper from "#veryfront/compat/path"; import { isDeno, isNode } from "#veryfront/platform/compat/runtime.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { isCompiledBinary } from "#veryfront/utils"; +import { wrapWithCurrentContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; const FILE_EXTENSIONS: string[] = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs"]; @@ -164,25 +165,28 @@ function createImportMapPlugin( return { path: absolutePath, namespace: "import-map" }; }); - build.onLoad({ filter: /.*/, namespace: "import-map" }, async (args) => { - try { - const { filePath, contents } = await readFileWithExtensions( - adapter, - args.path, - FILE_EXTENSIONS, - ); - - return { - contents, - loader: getLoaderForFile(filePath), - resolveDir: pathHelper.dirname(filePath), - }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`[API] Failed to load file via import map: ${args.path}`, error); - return { errors: [{ text: `Failed to load: ${msg}` }] }; - } - }); + build.onLoad( + { filter: /.*/, namespace: "import-map" }, + wrapWithCurrentContext(async (args) => { + try { + const { filePath, contents } = await readFileWithExtensions( + adapter, + args.path, + FILE_EXTENSIONS, + ); + + return { + contents, + loader: getLoaderForFile(filePath), + resolveDir: pathHelper.dirname(filePath), + }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`[API] Failed to load file via import map: ${args.path}`, error); + return { errors: [{ text: `Failed to load: ${msg}` }] }; + } + }), + ); }, }; } @@ -207,25 +211,33 @@ function createAdapterResolvePlugin(adapter: RuntimeAdapter): Plugin { return { path: absolutePath, namespace: "vf-adapter" }; }); - build.onLoad({ filter: /.*/, namespace: "vf-adapter" }, async (args) => { - try { - const { filePath, contents } = await readFileWithExtensions( - adapter, - args.path, - FILE_EXTENSIONS, - ); - - return { - contents, - loader: getLoaderForFile(filePath), - resolveDir: pathHelper.dirname(filePath), - }; - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`[API] Failed to load via adapter: ${args.path}`, error); - return { errors: [{ text: `Failed to load: ${msg}` }] }; - } - }); + // Wrap the onLoad callback with wrapWithCurrentContext to preserve the + // AsyncLocalStorage context. esbuild runs in a child process and its plugin + // callbacks fire from the child process message handler, losing the + // AsyncLocalStorage store. Without this, MultiProjectFSAdapter.getAdapter() + // cannot resolve the per-project adapter and all file reads fail silently. + build.onLoad( + { filter: /.*/, namespace: "vf-adapter" }, + wrapWithCurrentContext(async (args) => { + try { + const { filePath, contents } = await readFileWithExtensions( + adapter, + args.path, + FILE_EXTENSIONS, + ); + + return { + contents, + loader: getLoaderForFile(filePath), + resolveDir: pathHelper.dirname(filePath), + }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`[API] Failed to load via adapter: ${args.path}`, error); + return { errors: [{ text: `Failed to load: ${msg}` }] }; + } + }), + ); }, }; }