Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/nitro-cloudflare-runtime-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"evlog": patch
---

Fix Nitro server builds on strict Worker presets (e.g. `cloudflare-durable`) by avoiding Rollup-resolvable literals for `nitro/runtime-config` in published dist. Centralize runtime config access in an internal bridge (`__EVLOG_CONFIG` first, then dynamic `import()` with computed module specifiers for Nitro v3 and nitropack). Add regression tests for dist output and a `cloudflare-durable` production build using the compiled plugin.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ apps/web/.data
apps/chat/.data
.codex/environments/
.claude/
packages/evlog/test/nitro-v3/fixture/.wrangler
apps/nuxthub-playground/.data
.next/
37 changes: 7 additions & 30 deletions packages/evlog/src/adapters/_config.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
import { getNitroRuntimeConfigRecord } from '../shared/nitroConfigBridge'

/**
* Nitro runtime modules resolved via dynamic `import()` (Workers-safe: avoids a bundler-injected
* `createRequire` polyfill from sync `require()`). Module namespaces are cached after first
* successful load; `useRuntimeConfig()` is still invoked on each call so config stays current.
* Adapter runtime-config reads go through `getNitroRuntimeConfigRecord` in
* `shared/nitroConfigBridge.ts` (documented there — Workers-safe dynamic imports).
*
* Drain handlers remain non-blocking for the HTTP response when the host provides `waitUntil`
* (see Nitro plugin); the extra `await` here only sequences work inside that background drain.
* Drain handlers remain non-blocking when the host provides `waitUntil`.
*/
let nitropackRuntime: typeof import('nitropack/runtime') | null | undefined
let nitroV3Runtime: typeof import('nitro/runtime-config') | null | undefined

export async function getRuntimeConfig(): Promise<Record<string, any> | undefined> {
if (nitropackRuntime === undefined) {
try {
nitropackRuntime = await import('nitropack/runtime')
} catch {
nitropackRuntime = null
}
}
if (nitropackRuntime) {
return nitropackRuntime.useRuntimeConfig()
}

if (nitroV3Runtime === undefined) {
try {
nitroV3Runtime = await import('nitro/runtime-config')
} catch {
nitroV3Runtime = null
}
}
if (nitroV3Runtime) {
return nitroV3Runtime.useRuntimeConfig()
}
return undefined
export function getRuntimeConfig(): Promise<Record<string, any> | undefined> {
return getNitroRuntimeConfigRecord()
}

export interface ConfigField<T> {
Expand Down
11 changes: 3 additions & 8 deletions packages/evlog/src/nitro-v3/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { definePlugin } from 'nitro'
import { useRuntimeConfig } from 'nitro/runtime-config'
import type { CaptureError } from 'nitro/types'
import type { HTTPEvent } from 'nitro/h3'
import { parseURL } from 'ufo'
import { createRequestLogger, initLogger, isEnabled } from '../logger'
import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro'
import type { EvlogConfig } from '../nitro'
import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge'
import type { EnrichContext, RequestLogger, TailSamplingContext, WideEvent } from '../types'
import { filterSafeHeaders } from '../utils'

Expand Down Expand Up @@ -131,12 +130,8 @@ async function callEnrichAndDrain(
* export { default } from 'evlog/nitro/v3'
* ```
*/
export default definePlugin((nitroApp) => {
// In production builds the plugin is bundled and useRuntimeConfig()
// resolves the virtual module correctly. In dev mode the plugin is
// loaded externally so useRuntimeConfig() returns a stub — fall back
// to the env var bridge set by the module.
const evlogConfig = (useRuntimeConfig().evlog ?? (process.env.__EVLOG_CONFIG ? JSON.parse(process.env.__EVLOG_CONFIG) : undefined)) as EvlogConfig | undefined
export default definePlugin(async (nitroApp) => {
const evlogConfig = await resolveEvlogConfigForNitroPlugin()

initLogger({
enabled: evlogConfig?.enabled,
Expand Down
18 changes: 2 additions & 16 deletions packages/evlog/src/nitro/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { defineNitroPlugin } from 'nitropack/runtime/internal/plugin'
import { getHeaders } from 'h3'
import { createRequestLogger, initLogger, isEnabled } from '../logger'
import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro'
import type { EvlogConfig } from '../nitro'
import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge'
import type { EnrichContext, RequestLogger, ServerEvent, TailSamplingContext, WideEvent } from '../types'
import { filterSafeHeaders } from '../utils'

Expand Down Expand Up @@ -106,21 +106,7 @@ async function callEnrichAndDrain(
}

export default defineNitroPlugin(async (nitroApp) => {
// Config resolution: process.env bridge first (always set by the module),
// then lazy useRuntimeConfig() for production builds where env may not persist.
let evlogConfig: EvlogConfig | undefined
if (process.env.__EVLOG_CONFIG) {
evlogConfig = JSON.parse(process.env.__EVLOG_CONFIG)
} else {
try {
// nitropack/runtime/internal/config imports virtual modules —
// only works inside rollup-bundled output (production builds).
const { useRuntimeConfig } = await import('nitropack/runtime/internal/config')
evlogConfig = (useRuntimeConfig() as Record<string, any>).evlog
} catch {
// Expected in dev mode — virtual modules unavailable outside rollup
}
}
const evlogConfig = await resolveEvlogConfigForNitroPlugin()

initLogger({
enabled: evlogConfig?.enabled,
Expand Down
135 changes: 135 additions & 0 deletions packages/evlog/src/shared/nitroConfigBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* How evlog reads Nitro runtime config from **published** ESM.
*
* **Why not** `import('nitro/runtime-config')` as a string literal in source?
* Those subpaths are virtual or specially resolved. App Rollup can resolve them
* for first-party code; for dependency chunks (`node_modules/evlog/dist/...`),
* strict presets (e.g. `cloudflare-durable`) may fail with “externals are not
* allowed”. A literal dynamic import is enough for Rollup to pre-resolve.
*
* **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
* 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.
*
* Not exported from `evlog/toolkit` — package-internal only.
*/

import type { EvlogConfig } from '../nitro'

const EVLOG_NITRO_ENV = '__EVLOG_CONFIG' as const

type NitroRuntimeConfigModule = {
useRuntimeConfig: () => Record<string, any>
}

function nitroV3RuntimeConfigSpecifier(): string {
return ['nitro', 'runtime-config'].join('/')
}

function nitropackRuntimeSpecifier(): string {
return ['nitropack', 'runtime'].join('/')
}

function nitropackInternalRuntimeConfigSpecifier(): string {
return ['nitropack', 'runtime', 'internal', 'config'].join('/')
}

async function importOrNull(specifier: string): Promise<unknown> {
try {
return await import(specifier)
} catch {
return null
}
}

function isRuntimeConfigModule(mod: unknown): mod is NitroRuntimeConfigModule {
return (
typeof mod === 'object'
&& mod !== null
&& 'useRuntimeConfig' in mod
&& typeof (mod as NitroRuntimeConfigModule).useRuntimeConfig === 'function'
)
}

/** Snapshot from env, or `undefined` if unset / invalid JSON. */
export function readEvlogConfigFromNitroEnv(): EvlogConfig | undefined {
const raw = process.env[EVLOG_NITRO_ENV]
if (raw === undefined || raw === '') return undefined
try {
return JSON.parse(raw) as EvlogConfig
} catch {
return undefined
}
}

let cachedNitropackRuntime: NitroRuntimeConfigModule | null | undefined
let cachedNitroV3Runtime: NitroRuntimeConfigModule | null | undefined
let cachedNitropackInternalConfig: NitroRuntimeConfigModule | null | undefined

async function getNitropackRuntime(): Promise<NitroRuntimeConfigModule | null> {
if (cachedNitropackRuntime !== undefined) return cachedNitropackRuntime
const mod = await importOrNull(nitropackRuntimeSpecifier())
cachedNitropackRuntime = isRuntimeConfigModule(mod) ? mod : null
return cachedNitropackRuntime
}

async function getNitroV3Runtime(): Promise<NitroRuntimeConfigModule | null> {
if (cachedNitroV3Runtime !== undefined) return cachedNitroV3Runtime
const mod = await importOrNull(nitroV3RuntimeConfigSpecifier())
cachedNitroV3Runtime = isRuntimeConfigModule(mod) ? mod : null
return cachedNitroV3Runtime
}

async function getNitropackInternalRuntimeConfig(): Promise<NitroRuntimeConfigModule | null> {
if (cachedNitropackInternalConfig !== undefined) return cachedNitropackInternalConfig
const mod = await importOrNull(nitropackInternalRuntimeConfigSpecifier())
cachedNitropackInternalConfig = isRuntimeConfigModule(mod) ? mod : null
return cachedNitropackInternalConfig
}

function evlogSlice(config: Record<string, any>): EvlogConfig | undefined {
const { evlog } = config
if (evlog && typeof evlog === 'object') return evlog as EvlogConfig
return undefined
}

/**
* Options for evlog Nitro plugins (nitropack v2 and Nitro v3).
* Env bridge first; then Nitro v3 `runtime-config`; then nitropack internal config.
*/
export async function resolveEvlogConfigForNitroPlugin(): Promise<EvlogConfig | undefined> {
const fromEnv = readEvlogConfigFromNitroEnv()
if (fromEnv !== undefined) return fromEnv

const v3 = await getNitroV3Runtime()
if (v3) {
const slice = evlogSlice(v3.useRuntimeConfig())
if (slice !== undefined) return slice
}

const internal = await getNitropackInternalRuntimeConfig()
if (internal) {
const slice = evlogSlice(internal.useRuntimeConfig())
if (slice !== undefined) return slice
}

return undefined
}

/**
* Full `useRuntimeConfig()` object for drain adapters (nitropack first, then v3).
*/
export async function getNitroRuntimeConfigRecord(): Promise<Record<string, any> | undefined> {
const nitropack = await getNitropackRuntime()
if (nitropack) return nitropack.useRuntimeConfig()

const v3 = await getNitroV3Runtime()
if (v3) return v3.useRuntimeConfig()

return undefined
}
35 changes: 35 additions & 0 deletions packages/evlog/test/nitro-v3/cloudflare-durable-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { afterAll, describe, expect, it } from 'vitest'
import { build, createNitro } from 'nitro/builder'
import { resolve } from 'pathe'

/**
* Regression: strict Worker presets bundle evlog into the server output.
* Static or Rollup-resolvable `import("nitro/runtime-config")` from published
* dist used to fail with "Cannot resolve ... externals are not allowed".
*/
describe.sequential('Nitro cloudflare-durable build with evlog dist', () => {
let nitro: Awaited<ReturnType<typeof createNitro>>

afterAll(async () => {
await nitro?.close()
})

it('production build succeeds when the plugin is loaded from dist', async () => {
const fixtureDir = resolve(__dirname, './fixture')
const evlogDist = resolve(__dirname, '../../dist')

nitro = await createNitro({
rootDir: fixtureDir,
preset: 'cloudflare-durable',
dev: false,
compatibilityDate: '2024-01-16',
serverDir: './',
plugins: [resolve(evlogDist, 'nitro/v3/plugin.mjs')],
alias: {
evlog: resolve(evlogDist, 'index.mjs'),
},
})

await expect(build(nitro)).resolves.toBeUndefined()
}, 120_000)
})
36 changes: 36 additions & 0 deletions packages/evlog/test/worker-preset-dist-imports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { readdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'

const distDir = join(dirname(fileURLToPath(import.meta.url)), '../dist')

async function collectMjsFiles(dir: string, out: string[] = []): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const p = join(dir, e.name)
if (e.isDirectory()) await collectMjsFiles(p, out)
else if (e.isFile() && e.name.endsWith('.mjs') && !e.name.endsWith('.map')) out.push(p)
}
return out
}

describe('published dist avoids static nitro virtual imports', () => {
it('no .mjs file contains a resolvable nitro/runtime-config module specifier', async () => {
const files = await collectMjsFiles(distDir)
expect(files.length).toBeGreaterThan(0)

const forbidden = [
'"nitro/runtime-config"',
'\'nitro/runtime-config\'',
'`nitro/runtime-config`',
]

for (const file of files) {
const src = await readFile(file, 'utf8')
for (const needle of forbidden) {
expect(src, file).not.toContain(needle)
}
}
})
})
Loading