From 2ca67f8643b5b2bc2e56ba47200b3bdb5f976208 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 3 Apr 2026 16:06:46 +0100 Subject: [PATCH 1/2] fix(nitro): bundle-safe Nitro runtime config for cloudflare-durable --- .changeset/nitro-cloudflare-runtime-config.md | 5 + .gitignore | 1 + packages/evlog/src/adapters/_config.ts | 35 +---- packages/evlog/src/nitro-v3/plugin.ts | 11 +- packages/evlog/src/nitro/plugin.ts | 18 +-- .../evlog/src/shared/nitroConfigBridge.ts | 135 ++++++++++++++++++ .../nitro-v3/cloudflare-durable-build.test.ts | 35 +++++ .../test/worker-preset-dist-imports.test.ts | 36 +++++ 8 files changed, 223 insertions(+), 53 deletions(-) create mode 100644 .changeset/nitro-cloudflare-runtime-config.md create mode 100644 packages/evlog/src/shared/nitroConfigBridge.ts create mode 100644 packages/evlog/test/nitro-v3/cloudflare-durable-build.test.ts create mode 100644 packages/evlog/test/worker-preset-dist-imports.test.ts diff --git a/.changeset/nitro-cloudflare-runtime-config.md b/.changeset/nitro-cloudflare-runtime-config.md new file mode 100644 index 00000000..2e9174dd --- /dev/null +++ b/.changeset/nitro-cloudflare-runtime-config.md @@ -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. diff --git a/.gitignore b/.gitignore index 24396139..03e7ea16 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/packages/evlog/src/adapters/_config.ts b/packages/evlog/src/adapters/_config.ts index 7a253431..1190eac2 100644 --- a/packages/evlog/src/adapters/_config.ts +++ b/packages/evlog/src/adapters/_config.ts @@ -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 | 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 + return getNitroRuntimeConfigRecord() } export interface ConfigField { diff --git a/packages/evlog/src/nitro-v3/plugin.ts b/packages/evlog/src/nitro-v3/plugin.ts index dacbd55f..b62ae275 100644 --- a/packages/evlog/src/nitro-v3/plugin.ts +++ b/packages/evlog/src/nitro-v3/plugin.ts @@ -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' @@ -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, diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index e385b67d..feea9151 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -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' @@ -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).evlog - } catch { - // Expected in dev mode — virtual modules unavailable outside rollup - } - } + const evlogConfig = await resolveEvlogConfigForNitroPlugin() initLogger({ enabled: evlogConfig?.enabled, diff --git a/packages/evlog/src/shared/nitroConfigBridge.ts b/packages/evlog/src/shared/nitroConfigBridge.ts new file mode 100644 index 00000000..abfbcfb7 --- /dev/null +++ b/packages/evlog/src/shared/nitroConfigBridge.ts @@ -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 +} + +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 { + 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 == null || 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 { + if (cachedNitropackRuntime !== undefined) return cachedNitropackRuntime + const mod = await importOrNull(nitropackRuntimeSpecifier()) + cachedNitropackRuntime = isRuntimeConfigModule(mod) ? mod : null + return cachedNitropackRuntime +} + +async function getNitroV3Runtime(): Promise { + if (cachedNitroV3Runtime !== undefined) return cachedNitroV3Runtime + const mod = await importOrNull(nitroV3RuntimeConfigSpecifier()) + cachedNitroV3Runtime = isRuntimeConfigModule(mod) ? mod : null + return cachedNitroV3Runtime +} + +async function getNitropackInternalRuntimeConfig(): Promise { + if (cachedNitropackInternalConfig !== undefined) return cachedNitropackInternalConfig + const mod = await importOrNull(nitropackInternalRuntimeConfigSpecifier()) + cachedNitropackInternalConfig = isRuntimeConfigModule(mod) ? mod : null + return cachedNitropackInternalConfig +} + +function evlogSlice(config: Record): EvlogConfig | undefined { + const evlog = config.evlog + 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 { + 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 | undefined> { + const nitropack = await getNitropackRuntime() + if (nitropack) return nitropack.useRuntimeConfig() + + const v3 = await getNitroV3Runtime() + if (v3) return v3.useRuntimeConfig() + + return undefined +} diff --git a/packages/evlog/test/nitro-v3/cloudflare-durable-build.test.ts b/packages/evlog/test/nitro-v3/cloudflare-durable-build.test.ts new file mode 100644 index 00000000..70a5ed22 --- /dev/null +++ b/packages/evlog/test/nitro-v3/cloudflare-durable-build.test.ts @@ -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> + + 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) +}) diff --git a/packages/evlog/test/worker-preset-dist-imports.test.ts b/packages/evlog/test/worker-preset-dist-imports.test.ts new file mode 100644 index 00000000..792371c1 --- /dev/null +++ b/packages/evlog/test/worker-preset-dist-imports.test.ts @@ -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 { + 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) + } + } + }) +}) From fc5ec475d1c0649b6156bc243354e72f595c1fbc Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 3 Apr 2026 16:22:21 +0100 Subject: [PATCH 2/2] up --- packages/evlog/src/adapters/_config.ts | 2 +- packages/evlog/src/shared/nitroConfigBridge.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/evlog/src/adapters/_config.ts b/packages/evlog/src/adapters/_config.ts index 1190eac2..8bac1853 100644 --- a/packages/evlog/src/adapters/_config.ts +++ b/packages/evlog/src/adapters/_config.ts @@ -7,7 +7,7 @@ import { getNitroRuntimeConfigRecord } from '../shared/nitroConfigBridge' * Drain handlers remain non-blocking when the host provides `waitUntil`. */ -export async function getRuntimeConfig(): Promise | undefined> { +export function getRuntimeConfig(): Promise | undefined> { return getNitroRuntimeConfigRecord() } diff --git a/packages/evlog/src/shared/nitroConfigBridge.ts b/packages/evlog/src/shared/nitroConfigBridge.ts index abfbcfb7..66a7d511 100644 --- a/packages/evlog/src/shared/nitroConfigBridge.ts +++ b/packages/evlog/src/shared/nitroConfigBridge.ts @@ -59,7 +59,7 @@ function isRuntimeConfigModule(mod: unknown): mod is NitroRuntimeConfigModule { /** Snapshot from env, or `undefined` if unset / invalid JSON. */ export function readEvlogConfigFromNitroEnv(): EvlogConfig | undefined { const raw = process.env[EVLOG_NITRO_ENV] - if (raw == null || raw === '') return undefined + if (raw === undefined || raw === '') return undefined try { return JSON.parse(raw) as EvlogConfig } catch { @@ -93,7 +93,7 @@ async function getNitropackInternalRuntimeConfig(): Promise): EvlogConfig | undefined { - const evlog = config.evlog + const { evlog } = config if (evlog && typeof evlog === 'object') return evlog as EvlogConfig return undefined }