diff --git a/.changeset/nitro-bridge-active-runtime.md b/.changeset/nitro-bridge-active-runtime.md new file mode 100644 index 00000000..319153de --- /dev/null +++ b/.changeset/nitro-bridge-active-runtime.md @@ -0,0 +1,11 @@ +--- +'evlog': patch +--- + +Fix a runtime crash on Vercel + Bun + Nitro v3 where evlog probed `nitropack/runtime/internal/config` even though only Nitro v3 was installed. Bun's auto-install kicked in for the missing dependency and tried to write `node_modules/.cache`, which crashes on Vercel's read-only function filesystem with `bun is unable to write files: ReadOnlyFileSystem`. + +The Nitro plugins now declare their major version once (via the new internal `setActiveNitroRuntime` helper) and the shared config bridge probes only the matching runtime — `nitro/runtime-config` for v3, `nitropack/...` for v2. Adapters resolving config through `runtimeConfig.evlog.` benefit from the same restriction, so `createPostHogDrain()` (and any adapter using `resolveAdapterConfig`) no longer triggers the cross-version probe. + +No public-API change. The `process.env.__EVLOG_CONFIG` fast path remains the highest-priority lookup. + +Closes [#312](https://github.com/HugoRCD/evlog/issues/312). diff --git a/packages/evlog/src/nitro-v3/plugin.ts b/packages/evlog/src/nitro-v3/plugin.ts index 865e33ac..c1558f70 100644 --- a/packages/evlog/src/nitro-v3/plugin.ts +++ b/packages/evlog/src/nitro-v3/plugin.ts @@ -5,7 +5,7 @@ import { parseURL } from 'ufo' import { createRequestLogger, getGlobalPluginRunner, initLogger, isEnabled } from '../logger' import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro' import { normalizeRedactConfig } from '../redact' -import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge' +import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge' import type { EnrichContext, RequestLogger, TailSamplingContext, WideEvent } from '../types' import { filterSafeHeaders } from '../utils' @@ -150,6 +150,7 @@ async function callEnrichAndDrain( * ``` */ export default definePlugin(async (nitroApp) => { + setActiveNitroRuntime('v3') const evlogConfig = await resolveEvlogConfigForNitroPlugin() const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record | undefined) diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 0c2047fa..260fcaf1 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -8,7 +8,7 @@ import { getHeaders } from 'h3' import { createRequestLogger, getGlobalPluginRunner, initLogger, isEnabled } from '../logger' import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro' import { normalizeRedactConfig } from '../redact' -import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge' +import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge' import { startStreamServer, type StreamServerOptions } from '../stream' import type { EnrichContext, RequestLogger, ServerEvent, TailSamplingContext, WideEvent } from '../types' import { filterSafeHeaders } from '../utils' @@ -120,6 +120,7 @@ async function callEnrichAndDrain( } export default defineNitroPlugin(async (nitroApp) => { + setActiveNitroRuntime('v2') const evlogConfig = await resolveEvlogConfigForNitroPlugin() const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record | undefined) diff --git a/packages/evlog/src/shared/nitroConfigBridge.ts b/packages/evlog/src/shared/nitroConfigBridge.ts index 288cd4d3..1f8bcb34 100644 --- a/packages/evlog/src/shared/nitroConfigBridge.ts +++ b/packages/evlog/src/shared/nitroConfigBridge.ts @@ -13,8 +13,11 @@ * modules; preferred in production Workers builds). * 2. Computed module IDs — `['a','b'].join('/')` passed to `import()` so emitted * JS does not contain a static `import("a/b")`. - * 3. Plugin resolution tries Nitro v3 first, then nitropack internal config (v2). - * 4. Adapter resolution keeps historical order: nitropack runtime barrel, then v3. + * 3. Plugins call {@link setActiveNitroRuntime} so adapters never probe modules + * from the other major version (e.g. Bun on Vercel triggers a package-cache + * write when probing a missing dynamic import — see issue #312). + * 4. When the active runtime is unknown (standalone use outside a Nitro + * plugin), the bridge falls back to the historical probe order. * * Not exported from `evlog/toolkit` — package-internal only. */ @@ -25,6 +28,29 @@ type EvlogConfig = NitroPluginEvlogConfig const EVLOG_NITRO_ENV = '__EVLOG_CONFIG' as const +type NitroMajor = 'v2' | 'v3' + +let activeNitroRuntime: NitroMajor | undefined + +/** + * Declare the active Nitro major version so adapters never probe the other + * version's modules at runtime. The evlog Nitro plugins call this in their + * first synchronous statement. + * + * Bun's auto-install behavior writes to `node_modules/.cache` whenever a + * dynamic import targets a missing package, which crashes on Vercel's + * read-only function filesystem. Restricting probes to the runtime that is + * actually installed avoids that path entirely. + */ +export function setActiveNitroRuntime(version: NitroMajor): void { + activeNitroRuntime = version +} + +/** @internal Reset the active runtime declaration. Used by tests only. */ +export function resetActiveNitroRuntime(): void { + activeNitroRuntime = undefined +} + type NitroRuntimeConfigModule = { useRuntimeConfig: () => Record } @@ -102,12 +128,36 @@ function evlogSlice(config: Record): EvlogConfig | undefined { /** * Options for evlog Nitro plugins (nitropack v2 and Nitro v3). - * Env bridge first; then Nitro v3 `runtime-config`; then nitropack internal config. + * + * Lookup order: + * 1. `process.env.__EVLOG_CONFIG` + * 2. The active runtime declared by {@link setActiveNitroRuntime} — either + * Nitro v3 `runtime-config` or nitropack internal config, never both. + * 3. When no active runtime has been declared (standalone use): probe v3 then + * nitropack v2 as a best-effort fallback. */ export async function resolveEvlogConfigForNitroPlugin(): Promise { const fromEnv = readEvlogConfigFromNitroEnv() if (fromEnv !== undefined) return fromEnv + if (activeNitroRuntime === 'v3') { + const v3 = await getNitroV3Runtime() + if (v3) { + const slice = evlogSlice(v3.useRuntimeConfig()) + if (slice !== undefined) return slice + } + return undefined + } + + if (activeNitroRuntime === 'v2') { + const internal = await getNitropackInternalRuntimeConfig() + if (internal) { + const slice = evlogSlice(internal.useRuntimeConfig()) + if (slice !== undefined) return slice + } + return undefined + } + const v3 = await getNitroV3Runtime() if (v3) { const slice = evlogSlice(v3.useRuntimeConfig()) @@ -124,9 +174,24 @@ export async function resolveEvlogConfigForNitroPlugin(): Promise | undefined> { + if (activeNitroRuntime === 'v3') { + const v3 = await getNitroV3Runtime() + return v3 ? v3.useRuntimeConfig() : undefined + } + + if (activeNitroRuntime === 'v2') { + const nitropack = await getNitropackRuntime() + return nitropack ? nitropack.useRuntimeConfig() : undefined + } + const nitropack = await getNitropackRuntime() if (nitropack) return nitropack.useRuntimeConfig() diff --git a/packages/evlog/test/shared/nitroConfigBridge.test.ts b/packages/evlog/test/shared/nitroConfigBridge.test.ts new file mode 100644 index 00000000..01cad7af --- /dev/null +++ b/packages/evlog/test/shared/nitroConfigBridge.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + delete process.env.__EVLOG_CONFIG +}) + +afterEach(() => { + vi.resetModules() + vi.doUnmock(['nitro', 'runtime-config'].join('/')) + vi.doUnmock(['nitropack', 'runtime', 'internal', 'config'].join('/')) + vi.doUnmock(['nitropack', 'runtime'].join('/')) +}) + +async function loadBridgeWithMocks() { + const importSpy = vi.fn<(specifier: string) => void>() + vi.doMock(['nitro', 'runtime-config'].join('/'), () => { + importSpy('nitro/runtime-config') + return { useRuntimeConfig: () => ({ evlog: { env: { service: 'svc-v3' } }, posthog: { apiKey: 'phc-v3' } }) } + }) + vi.doMock(['nitropack', 'runtime', 'internal', 'config'].join('/'), () => { + importSpy('nitropack/runtime/internal/config') + return { useRuntimeConfig: () => ({ evlog: { env: { service: 'svc-v2' } }, posthog: { apiKey: 'phc-v2' } }) } + }) + vi.doMock(['nitropack', 'runtime'].join('/'), () => { + importSpy('nitropack/runtime') + return { useRuntimeConfig: () => ({ evlog: { env: { service: 'svc-v2-barrel' } }, posthog: { apiKey: 'phc-v2-barrel' } }) } + }) + const bridge = await import('../../src/shared/nitroConfigBridge') + return { bridge, importSpy } +} + +describe('nitroConfigBridge — active runtime', () => { + it('only probes Nitro v3 modules when v3 is the active runtime', async () => { + const { bridge, importSpy } = await loadBridgeWithMocks() + bridge.setActiveNitroRuntime('v3') + + const config = await bridge.resolveEvlogConfigForNitroPlugin() + const record = await bridge.getNitroRuntimeConfigRecord() + + expect(config).toEqual({ env: { service: 'svc-v3' } }) + expect(record).toEqual({ evlog: { env: { service: 'svc-v3' } }, posthog: { apiKey: 'phc-v3' } }) + + const probed = importSpy.mock.calls.map(call => call[0]) + expect(probed).toContain('nitro/runtime-config') + expect(probed).not.toContain('nitropack/runtime') + expect(probed).not.toContain('nitropack/runtime/internal/config') + }) + + it('only probes nitropack v2 modules when v2 is the active runtime', async () => { + const { bridge, importSpy } = await loadBridgeWithMocks() + bridge.setActiveNitroRuntime('v2') + + const config = await bridge.resolveEvlogConfigForNitroPlugin() + const record = await bridge.getNitroRuntimeConfigRecord() + + expect(config).toEqual({ env: { service: 'svc-v2' } }) + expect(record).toEqual({ evlog: { env: { service: 'svc-v2-barrel' } }, posthog: { apiKey: 'phc-v2-barrel' } }) + + const probed = importSpy.mock.calls.map(call => call[0]) + expect(probed).not.toContain('nitro/runtime-config') + }) + + it('reads __EVLOG_CONFIG from env without probing any Nitro module', async () => { + process.env.__EVLOG_CONFIG = JSON.stringify({ env: { service: 'svc-env' } }) + const { bridge, importSpy } = await loadBridgeWithMocks() + bridge.setActiveNitroRuntime('v3') + + const config = await bridge.resolveEvlogConfigForNitroPlugin() + + expect(config).toEqual({ env: { service: 'svc-env' } }) + expect(importSpy).not.toHaveBeenCalled() + }) + + it('falls back to historical probe order when no runtime is declared', async () => { + const { bridge, importSpy } = await loadBridgeWithMocks() + bridge.resetActiveNitroRuntime() + + const config = await bridge.resolveEvlogConfigForNitroPlugin() + + expect(config).toEqual({ env: { service: 'svc-v3' } }) + const probed = importSpy.mock.calls.map(call => call[0]) + expect(probed).toContain('nitro/runtime-config') + }) +})