Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/nitro-bridge-active-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'evlog': patch
---

Fix a runtime crash on Vercel + Bun + Nitro v3 where every request failed with `bun is unable to write files: ReadOnlyFileSystem`. The Nitro plugin probed `nitro/runtime-config` at runtime to read evlog's config; that module transitively imports the build-only `#nitro/virtual/runtime-config`, which doesn't exist in deployed bundles. On Vercel + Bun the missing virtual triggered Bun's package auto-installer, which tried to write `node_modules/.cache` and crashed on the read-only function filesystem.
Comment thread
HugoRCD marked this conversation as resolved.

The Nitro modules now bake the evlog config into the bundle as a literal via `nitro.options.replace.__EVLOG_CONFIG__`. The shared config bridge reads that build-time literal first and skips all runtime probing β€” no `import('nitro/runtime-config')`, no env propagation guesswork. The bridge also exposes the inlined value as a synthetic `{ evlog: <inlined> }` record, so drain adapters resolving `runtimeConfig.evlog.<adapter>` never trigger the probe either.

For defense-in-depth, the bridge additionally scopes its dynamic-import fallback to the major version declared by the plugin (new internal `setActiveNitroRuntime` helper) β€” `nitro/runtime-config` for v3, `nitropack/...` for v2 β€” so standalone use outside a plugin (e.g. adapters called from non-Nitro code) doesn't probe both versions.

No public-API change.

Closes [#312](https://github.com/HugoRCD/evlog/issues/312).
9 changes: 9 additions & 0 deletions packages/evlog/src/nitro-v3/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export default function evlog(options?: NitroModuleOptions) {
nitro.options.runtimeConfig = nitro.options.runtimeConfig || {}
nitro.options.runtimeConfig.evlog = options || {}

// Bake the config into the bundle as a literal so the plugin never has
// to do a runtime `import('nitro/runtime-config')` to discover it. That
// dynamic import resolves to a module which itself imports the build-only
// virtual `#nitro/virtual/runtime-config` β€” fine inside the Nitro build,
// but on Vercel + Bun the missing virtual triggers Bun's auto-installer
// and crashes with `ReadOnlyFileSystem` (see issue #312).
nitro.options.replace = nitro.options.replace || {}
nitro.options.replace.__EVLOG_CONFIG__ = JSON.stringify(options || {})

// In dev mode, Nitro loads plugins externally (not bundled), so the
// virtual runtime-config module is unreachable and useRuntimeConfig()
// returns a stub without our values. process.env is inherited by the
Expand Down
3 changes: 2 additions & 1 deletion packages/evlog/src/nitro-v3/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, unknown> | undefined)
Expand Down
8 changes: 8 additions & 0 deletions packages/evlog/src/nitro/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export default function evlog(options?: NitroModuleOptions) {
nitro.options.runtimeConfig = nitro.options.runtimeConfig || {}
nitro.options.runtimeConfig.evlog = options || {}

// Bake the config into the bundle as a literal so the plugin never has
// to do a runtime `import('nitropack/runtime/internal/config')` to
// discover it. The dynamic probe transitively imports a build-only
// virtual module; on Vercel + Bun the missing virtual triggers Bun's
// auto-installer and crashes with `ReadOnlyFileSystem` (issue #312).
nitro.options.replace = nitro.options.replace || {}
nitro.options.replace.__EVLOG_CONFIG__ = JSON.stringify(options || {})

// In dev mode, Nitro loads plugins externally (not bundled), so the
// virtual runtime-config module is unreachable and useRuntimeConfig()
// returns a stub without our values. process.env is inherited by the
Expand Down
3 changes: 2 additions & 1 deletion packages/evlog/src/nitro/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown> | undefined)
Expand Down
106 changes: 99 additions & 7 deletions packages/evlog/src/shared/nitroConfigBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@
*
* **Strategy**
*
* 1. `process.env.__EVLOG_CONFIG` β€” JSON set by evlog Nitro modules (no virtual
* modules; preferred in production Workers builds).
* 2. Computed module IDs β€” `['a','b'].join('/')` passed to `import()` so emitted
* 1. `__EVLOG_CONFIG__` β€” build-time literal baked in by the evlog Nitro
* modules via `nitro.options.replace`. When present, all runtime probing is
* skipped (see issue #312: Vercel + Bun crashes if the v3 probe runs).
* 2. `process.env.__EVLOG_CONFIG` β€” JSON set by evlog Nitro modules during
* build; survives into runtime on platforms that propagate build env vars.
* 3. 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.
* 4. Plugins call {@link setActiveNitroRuntime} so adapters never probe modules
* from the other major version.
* 5. 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.
*/
Expand All @@ -25,6 +30,42 @@ type EvlogConfig = NitroPluginEvlogConfig

const EVLOG_NITRO_ENV = '__EVLOG_CONFIG' as const

// Replaced at build time by `nitro.options.replace` in the evlog Nitro
// modules. Outside of a Nitro build, this identifier is undeclared and the
// `typeof` guard below evaluates safely.
// eslint-disable-next-line @typescript-eslint/naming-convention
declare const __EVLOG_CONFIG__: EvlogConfig | undefined

/** Build-time inlined config, or `undefined` if the bundle was not produced by an evlog Nitro module. */
export function readEvlogConfigFromInline(): EvlogConfig | undefined {
if (typeof __EVLOG_CONFIG__ === 'undefined') return undefined
if (__EVLOG_CONFIG__ === null || typeof __EVLOG_CONFIG__ !== 'object') return undefined
return __EVLOG_CONFIG__
}

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<string, any>
}
Expand Down Expand Up @@ -102,12 +143,41 @@ function evlogSlice(config: Record<string, any>): 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. `__EVLOG_CONFIG__` β€” inlined at build time by the evlog Nitro module.
* Hits in every deployed bundle and skips runtime probing entirely.
* 2. `process.env.__EVLOG_CONFIG`
* 3. The active runtime declared by {@link setActiveNitroRuntime} β€” either
* Nitro v3 `runtime-config` or nitropack internal config, never both.
* 4. When no active runtime has been declared (standalone use): probe v3 then
* nitropack v2 as a best-effort fallback.
*/
export async function resolveEvlogConfigForNitroPlugin(): Promise<EvlogConfig | undefined> {
const fromInline = readEvlogConfigFromInline()
if (fromInline !== undefined) return fromInline

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())
Expand All @@ -124,9 +194,31 @@ export async function resolveEvlogConfigForNitroPlugin(): Promise<EvlogConfig |
}

/**
* Full `useRuntimeConfig()` object for drain adapters (nitropack first, then v3).
* Full `useRuntimeConfig()` object for drain adapters.
*
* Honors {@link setActiveNitroRuntime}: when a Nitro plugin has declared its
* version, only that version's runtime module is probed. When no version has
* been declared (standalone use outside Nitro), falls back to the historical
* order: nitropack v2 first, then Nitro v3.
*
* When `__EVLOG_CONFIG__` was inlined at build time, returns a synthetic
* `{ evlog: <inlined> }` record so adapters can read `runtimeConfig.evlog.*`
* without triggering the dynamic import (issue #312).
*/
export async function getNitroRuntimeConfigRecord(): Promise<Record<string, any> | undefined> {
const inline = readEvlogConfigFromInline()
if (inline !== undefined) return { evlog: inline }

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()

Expand Down
129 changes: 129 additions & 0 deletions packages/evlog/test/shared/nitroConfigBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

declare global {
// eslint-disable-next-line @typescript-eslint/naming-convention
var __EVLOG_CONFIG__: unknown
}

beforeEach(() => {
vi.resetModules()
vi.unstubAllEnvs()
delete process.env.__EVLOG_CONFIG
delete (globalThis as { __EVLOG_CONFIG__?: unknown }).__EVLOG_CONFIG__
})

afterEach(() => {
vi.resetModules()
vi.doUnmock(['nitro', 'runtime-config'].join('/'))
vi.doUnmock(['nitropack', 'runtime', 'internal', 'config'].join('/'))
vi.doUnmock(['nitropack', 'runtime'].join('/'))
delete (globalThis as { __EVLOG_CONFIG__?: unknown }).__EVLOG_CONFIG__
})

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')
})

it('returns the build-time inlined __EVLOG_CONFIG__ without probing', async () => {
globalThis.__EVLOG_CONFIG__ = { env: { service: 'svc-inline' } }
const { bridge, importSpy } = await loadBridgeWithMocks()
bridge.setActiveNitroRuntime('v3')

const config = await bridge.resolveEvlogConfigForNitroPlugin()
const record = await bridge.getNitroRuntimeConfigRecord()

expect(config).toEqual({ env: { service: 'svc-inline' } })
expect(record).toEqual({ evlog: { env: { service: 'svc-inline' } } })
expect(importSpy).not.toHaveBeenCalled()
})

it('prefers __EVLOG_CONFIG__ over process.env.__EVLOG_CONFIG', async () => {
globalThis.__EVLOG_CONFIG__ = { env: { service: 'svc-inline' } }
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-inline' } })
expect(importSpy).not.toHaveBeenCalled()
})

it('ignores __EVLOG_CONFIG__ when it is not an object literal', async () => {
globalThis.__EVLOG_CONFIG__ = 'not-an-object'
const { bridge, importSpy } = await loadBridgeWithMocks()
bridge.setActiveNitroRuntime('v3')

const config = await bridge.resolveEvlogConfigForNitroPlugin()

expect(config).toEqual({ env: { service: 'svc-v3' } })
expect(importSpy.mock.calls.map(c => c[0])).toContain('nitro/runtime-config')
})
})
Loading