Skip to content

Commit c96967b

Browse files
authored
fix(nitro): bundle-safe runtime config for cloudflare-durable (#245)
1 parent d3383d5 commit c96967b

File tree

8 files changed

+224
-54
lines changed

8 files changed

+224
-54
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"evlog": patch
3+
---
4+
5+
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.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ apps/web/.data
4040
apps/chat/.data
4141
.codex/environments/
4242
.claude/
43+
packages/evlog/test/nitro-v3/fixture/.wrangler
4344
apps/nuxthub-playground/.data
4445
.next/

packages/evlog/src/adapters/_config.ts

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,14 @@
1+
import { getNitroRuntimeConfigRecord } from '../shared/nitroConfigBridge'
2+
13
/**
2-
* Nitro runtime modules resolved via dynamic `import()` (Workers-safe: avoids a bundler-injected
3-
* `createRequire` polyfill from sync `require()`). Module namespaces are cached after first
4-
* successful load; `useRuntimeConfig()` is still invoked on each call so config stays current.
4+
* Adapter runtime-config reads go through `getNitroRuntimeConfigRecord` in
5+
* `shared/nitroConfigBridge.ts` (documented there — Workers-safe dynamic imports).
56
*
6-
* Drain handlers remain non-blocking for the HTTP response when the host provides `waitUntil`
7-
* (see Nitro plugin); the extra `await` here only sequences work inside that background drain.
7+
* Drain handlers remain non-blocking when the host provides `waitUntil`.
88
*/
9-
let nitropackRuntime: typeof import('nitropack/runtime') | null | undefined
10-
let nitroV3Runtime: typeof import('nitro/runtime-config') | null | undefined
11-
12-
export async function getRuntimeConfig(): Promise<Record<string, any> | undefined> {
13-
if (nitropackRuntime === undefined) {
14-
try {
15-
nitropackRuntime = await import('nitropack/runtime')
16-
} catch {
17-
nitropackRuntime = null
18-
}
19-
}
20-
if (nitropackRuntime) {
21-
return nitropackRuntime.useRuntimeConfig()
22-
}
239

24-
if (nitroV3Runtime === undefined) {
25-
try {
26-
nitroV3Runtime = await import('nitro/runtime-config')
27-
} catch {
28-
nitroV3Runtime = null
29-
}
30-
}
31-
if (nitroV3Runtime) {
32-
return nitroV3Runtime.useRuntimeConfig()
33-
}
34-
return undefined
10+
export function getRuntimeConfig(): Promise<Record<string, any> | undefined> {
11+
return getNitroRuntimeConfigRecord()
3512
}
3613

3714
export interface ConfigField<T> {

packages/evlog/src/nitro-v3/plugin.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { definePlugin } from 'nitro'
2-
import { useRuntimeConfig } from 'nitro/runtime-config'
32
import type { CaptureError } from 'nitro/types'
43
import type { HTTPEvent } from 'nitro/h3'
54
import { parseURL } from 'ufo'
65
import { createRequestLogger, initLogger, isEnabled } from '../logger'
76
import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro'
8-
import type { EvlogConfig } from '../nitro'
7+
import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge'
98
import type { EnrichContext, RequestLogger, TailSamplingContext, WideEvent } from '../types'
109
import { filterSafeHeaders } from '../utils'
1110

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

141136
initLogger({
142137
enabled: evlogConfig?.enabled,

packages/evlog/src/nitro/plugin.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { defineNitroPlugin } from 'nitropack/runtime/internal/plugin'
77
import { getHeaders } from 'h3'
88
import { createRequestLogger, initLogger, isEnabled } from '../logger'
99
import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro'
10-
import type { EvlogConfig } from '../nitro'
10+
import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge'
1111
import type { EnrichContext, RequestLogger, ServerEvent, TailSamplingContext, WideEvent } from '../types'
1212
import { filterSafeHeaders } from '../utils'
1313

@@ -106,21 +106,7 @@ async function callEnrichAndDrain(
106106
}
107107

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

125111
initLogger({
126112
enabled: evlogConfig?.enabled,
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* How evlog reads Nitro runtime config from **published** ESM.
3+
*
4+
* **Why not** `import('nitro/runtime-config')` as a string literal in source?
5+
* Those subpaths are virtual or specially resolved. App Rollup can resolve them
6+
* for first-party code; for dependency chunks (`node_modules/evlog/dist/...`),
7+
* strict presets (e.g. `cloudflare-durable`) may fail with “externals are not
8+
* allowed”. A literal dynamic import is enough for Rollup to pre-resolve.
9+
*
10+
* **Strategy**
11+
*
12+
* 1. `process.env.__EVLOG_CONFIG` — JSON set by evlog Nitro modules (no virtual
13+
* modules; preferred in production Workers builds).
14+
* 2. Computed module IDs — `['a','b'].join('/')` passed to `import()` so emitted
15+
* JS does not contain a static `import("a/b")`.
16+
* 3. Plugin resolution tries Nitro v3 first, then nitropack internal config (v2).
17+
* 4. Adapter resolution keeps historical order: nitropack runtime barrel, then v3.
18+
*
19+
* Not exported from `evlog/toolkit` — package-internal only.
20+
*/
21+
22+
import type { EvlogConfig } from '../nitro'
23+
24+
const EVLOG_NITRO_ENV = '__EVLOG_CONFIG' as const
25+
26+
type NitroRuntimeConfigModule = {
27+
useRuntimeConfig: () => Record<string, any>
28+
}
29+
30+
function nitroV3RuntimeConfigSpecifier(): string {
31+
return ['nitro', 'runtime-config'].join('/')
32+
}
33+
34+
function nitropackRuntimeSpecifier(): string {
35+
return ['nitropack', 'runtime'].join('/')
36+
}
37+
38+
function nitropackInternalRuntimeConfigSpecifier(): string {
39+
return ['nitropack', 'runtime', 'internal', 'config'].join('/')
40+
}
41+
42+
async function importOrNull(specifier: string): Promise<unknown> {
43+
try {
44+
return await import(specifier)
45+
} catch {
46+
return null
47+
}
48+
}
49+
50+
function isRuntimeConfigModule(mod: unknown): mod is NitroRuntimeConfigModule {
51+
return (
52+
typeof mod === 'object'
53+
&& mod !== null
54+
&& 'useRuntimeConfig' in mod
55+
&& typeof (mod as NitroRuntimeConfigModule).useRuntimeConfig === 'function'
56+
)
57+
}
58+
59+
/** Snapshot from env, or `undefined` if unset / invalid JSON. */
60+
export function readEvlogConfigFromNitroEnv(): EvlogConfig | undefined {
61+
const raw = process.env[EVLOG_NITRO_ENV]
62+
if (raw === undefined || raw === '') return undefined
63+
try {
64+
return JSON.parse(raw) as EvlogConfig
65+
} catch {
66+
return undefined
67+
}
68+
}
69+
70+
let cachedNitropackRuntime: NitroRuntimeConfigModule | null | undefined
71+
let cachedNitroV3Runtime: NitroRuntimeConfigModule | null | undefined
72+
let cachedNitropackInternalConfig: NitroRuntimeConfigModule | null | undefined
73+
74+
async function getNitropackRuntime(): Promise<NitroRuntimeConfigModule | null> {
75+
if (cachedNitropackRuntime !== undefined) return cachedNitropackRuntime
76+
const mod = await importOrNull(nitropackRuntimeSpecifier())
77+
cachedNitropackRuntime = isRuntimeConfigModule(mod) ? mod : null
78+
return cachedNitropackRuntime
79+
}
80+
81+
async function getNitroV3Runtime(): Promise<NitroRuntimeConfigModule | null> {
82+
if (cachedNitroV3Runtime !== undefined) return cachedNitroV3Runtime
83+
const mod = await importOrNull(nitroV3RuntimeConfigSpecifier())
84+
cachedNitroV3Runtime = isRuntimeConfigModule(mod) ? mod : null
85+
return cachedNitroV3Runtime
86+
}
87+
88+
async function getNitropackInternalRuntimeConfig(): Promise<NitroRuntimeConfigModule | null> {
89+
if (cachedNitropackInternalConfig !== undefined) return cachedNitropackInternalConfig
90+
const mod = await importOrNull(nitropackInternalRuntimeConfigSpecifier())
91+
cachedNitropackInternalConfig = isRuntimeConfigModule(mod) ? mod : null
92+
return cachedNitropackInternalConfig
93+
}
94+
95+
function evlogSlice(config: Record<string, any>): EvlogConfig | undefined {
96+
const { evlog } = config
97+
if (evlog && typeof evlog === 'object') return evlog as EvlogConfig
98+
return undefined
99+
}
100+
101+
/**
102+
* Options for evlog Nitro plugins (nitropack v2 and Nitro v3).
103+
* Env bridge first; then Nitro v3 `runtime-config`; then nitropack internal config.
104+
*/
105+
export async function resolveEvlogConfigForNitroPlugin(): Promise<EvlogConfig | undefined> {
106+
const fromEnv = readEvlogConfigFromNitroEnv()
107+
if (fromEnv !== undefined) return fromEnv
108+
109+
const v3 = await getNitroV3Runtime()
110+
if (v3) {
111+
const slice = evlogSlice(v3.useRuntimeConfig())
112+
if (slice !== undefined) return slice
113+
}
114+
115+
const internal = await getNitropackInternalRuntimeConfig()
116+
if (internal) {
117+
const slice = evlogSlice(internal.useRuntimeConfig())
118+
if (slice !== undefined) return slice
119+
}
120+
121+
return undefined
122+
}
123+
124+
/**
125+
* Full `useRuntimeConfig()` object for drain adapters (nitropack first, then v3).
126+
*/
127+
export async function getNitroRuntimeConfigRecord(): Promise<Record<string, any> | undefined> {
128+
const nitropack = await getNitropackRuntime()
129+
if (nitropack) return nitropack.useRuntimeConfig()
130+
131+
const v3 = await getNitroV3Runtime()
132+
if (v3) return v3.useRuntimeConfig()
133+
134+
return undefined
135+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterAll, describe, expect, it } from 'vitest'
2+
import { build, createNitro } from 'nitro/builder'
3+
import { resolve } from 'pathe'
4+
5+
/**
6+
* Regression: strict Worker presets bundle evlog into the server output.
7+
* Static or Rollup-resolvable `import("nitro/runtime-config")` from published
8+
* dist used to fail with "Cannot resolve ... externals are not allowed".
9+
*/
10+
describe.sequential('Nitro cloudflare-durable build with evlog dist', () => {
11+
let nitro: Awaited<ReturnType<typeof createNitro>>
12+
13+
afterAll(async () => {
14+
await nitro?.close()
15+
})
16+
17+
it('production build succeeds when the plugin is loaded from dist', async () => {
18+
const fixtureDir = resolve(__dirname, './fixture')
19+
const evlogDist = resolve(__dirname, '../../dist')
20+
21+
nitro = await createNitro({
22+
rootDir: fixtureDir,
23+
preset: 'cloudflare-durable',
24+
dev: false,
25+
compatibilityDate: '2024-01-16',
26+
serverDir: './',
27+
plugins: [resolve(evlogDist, 'nitro/v3/plugin.mjs')],
28+
alias: {
29+
evlog: resolve(evlogDist, 'index.mjs'),
30+
},
31+
})
32+
33+
await expect(build(nitro)).resolves.toBeUndefined()
34+
}, 120_000)
35+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { readdir, readFile } from 'node:fs/promises'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { describe, expect, it } from 'vitest'
5+
6+
const distDir = join(dirname(fileURLToPath(import.meta.url)), '../dist')
7+
8+
async function collectMjsFiles(dir: string, out: string[] = []): Promise<string[]> {
9+
const entries = await readdir(dir, { withFileTypes: true })
10+
for (const e of entries) {
11+
const p = join(dir, e.name)
12+
if (e.isDirectory()) await collectMjsFiles(p, out)
13+
else if (e.isFile() && e.name.endsWith('.mjs') && !e.name.endsWith('.map')) out.push(p)
14+
}
15+
return out
16+
}
17+
18+
describe('published dist avoids static nitro virtual imports', () => {
19+
it('no .mjs file contains a resolvable nitro/runtime-config module specifier', async () => {
20+
const files = await collectMjsFiles(distDir)
21+
expect(files.length).toBeGreaterThan(0)
22+
23+
const forbidden = [
24+
'"nitro/runtime-config"',
25+
'\'nitro/runtime-config\'',
26+
'`nitro/runtime-config`',
27+
]
28+
29+
for (const file of files) {
30+
const src = await readFile(file, 'utf8')
31+
for (const needle of forbidden) {
32+
expect(src, file).not.toContain(needle)
33+
}
34+
}
35+
})
36+
})

0 commit comments

Comments
 (0)