Environment
- Nitro:
3.0.260311-beta
- hookable:
6.1.0 (dependency of Nitro)
- Node.js:
v24.14.1
- OS: macOS (darwin 24.6.0)
- Package manager: pnpm
- Preset:
netlify (non-static, non-edge)
- Builder:
rollup (createNitro({ builder: 'rollup' }))
- Framework integration:
@analogjs/vite-plugin-nitro (but the bug is entirely upstream — reproducible with plain nitro/builder)
Reproduction
Any Nitro 3 beta project built with preset: 'netlify'. Observe the server output directory — main.mjs is emitted but server.mjs is missing. Happy to put together a StackBlitz if needed; the issue is deterministic and reproduces on every build.
Describe the bug
When building with nitro@3.0.260311-beta + preset: 'netlify', the Netlify preset's compiled hook starts executing but is silently dropped by hookable before its first await resumes. As a result, server.mjs (the Netlify Function entrypoint) is never written, and any later work in that hook (Netlify image config, deploy/v1/config.json) is also skipped. The deployed site has no server handler at all.
The Netlify preset registers an async compiled(nitro) hook that writes server.mjs (nitro/dist/_presets.mjs:1122-1135):
hooks: { async compiled(nitro) {
await writeHeaders(nitro);
await writeRedirects(nitro);
await fs.writeFile(
join(nitro.options.output.dir, "server", "server.mjs"),
generateNetlifyFunction(nitro),
);
...
}}
I instrumented the build and confirmed:
nitro.options.hooks.compiled is the preset's AsyncFunction
nitro.hooks._hooks.compiled.length === 1
callHook("compiled", nitro) fires the listener
- The function body enters — a log placed on the first line of the hook body prints
But hookable@6.1.0 then drops the pending work (hookable/dist/index.mjs:33-39):
const result = task ? task.run(() => hooks[i](...args)) : hooks[i](...args);
if (result instanceof Promise) return result.then(() => callHooks(...));
For this compiled hook under Nitro 3's config loader, the returned value is:
typeof result === 'object'
result.constructor.name === 'Promise'
typeof result.then === 'function'
result instanceof Promise === false
result instanceof globalThis.Promise === false, even though Promise === globalThis.Promise in hookable's scope
This is a classic cross-realm instanceof failure: the Promise was constructed by a Promise binding from a different realm than the one hookable closes over. The likely culprit is c12 resolving the preset through jiti, which can execute modules in a VM context with its own intrinsics.
Because instanceof Promise is false, hookable returns synchronously without awaiting. build() proceeds to nitro.close() and the process tears down before the writeFile(.../server.mjs) microtask chain resolves.
Impact is not limited to Netlify — any Nitro 3 preset whose compiled hook is async is at risk of silently skipping post-compile work under the same config-loading path.
Suggested fix
Either (or both):
- hookable: match thenables, not just
Promise instances. instanceof is unsafe across realms; typeof result?.then === 'function' is the standard thenable check.
- Nitro 3 / c12: avoid resolving presets through a jiti VM context so hook functions defined in the preset close over the main-realm
Promise.
Happy to submit a PR against hookable to accept thenables if that's the preferred path.
Additional context
Confirmed fix locally by patching hookable's callHooks:
if (result && typeof result.then === 'function' && !(result instanceof Promise)) {
return Promise.resolve(result).then(() => callHooks(hooks, args, i + 1, task));
}
if (result instanceof Promise) return result.then(() => callHooks(hooks, args, i + 1, task));
With this patch, server.mjs is written correctly on the next build and the hook body runs end-to-end. Reverted after verification.
Nitro 2.13.1 with the same preset pattern works fine — either the older hookable version or a different config loader path didn't produce a cross-realm Promise, so the instanceof check passed and the hook was awaited normally.
Logs
# Instrumented hookable callHooks output for the netlify compiled hook:
[BEFORE NETLIFY CALL] ctor= AsyncFunction global Promise same: true
[NETLIFY COMPILED HOOK START]
[AFTER NETLIFY CALL] isPromise= false isGlobalPromise= false ctor= Promise thenable= function
# (hookable returns here without awaiting; build tears down)
# When manually awaited inside hookable:
[after writeHeaders]
br
[NETLIFY awaited] returned: undefined
# server.mjs is now written
# Resulting server output dir contents (broken):
main.mjs
# server.mjs missing
Environment
3.0.260311-beta6.1.0(dependency of Nitro)v24.14.1netlify(non-static, non-edge)rollup(createNitro({ builder: 'rollup' }))@analogjs/vite-plugin-nitro(but the bug is entirely upstream — reproducible with plainnitro/builder)Reproduction
Any Nitro 3 beta project built with
preset: 'netlify'. Observe the server output directory —main.mjsis emitted butserver.mjsis missing. Happy to put together a StackBlitz if needed; the issue is deterministic and reproduces on every build.Describe the bug
When building with
nitro@3.0.260311-beta+preset: 'netlify', the Netlify preset'scompiledhook starts executing but is silently dropped byhookablebefore its firstawaitresumes. As a result,server.mjs(the Netlify Function entrypoint) is never written, and any later work in that hook (Netlify image config,deploy/v1/config.json) is also skipped. The deployed site has no server handler at all.The Netlify preset registers an
async compiled(nitro)hook that writesserver.mjs(nitro/dist/_presets.mjs:1122-1135):I instrumented the build and confirmed:
nitro.options.hooks.compiledis the preset'sAsyncFunctionnitro.hooks._hooks.compiled.length === 1callHook("compiled", nitro)fires the listenerBut
hookable@6.1.0then drops the pending work (hookable/dist/index.mjs:33-39):For this
compiledhook under Nitro 3's config loader, the returned value is:typeof result === 'object'result.constructor.name === 'Promise'typeof result.then === 'function'result instanceof Promise === falseresult instanceof globalThis.Promise === false, even thoughPromise === globalThis.Promiseinhookable's scopeThis is a classic cross-realm
instanceoffailure: the Promise was constructed by aPromisebinding from a different realm than the onehookablecloses over. The likely culprit is c12 resolving the preset through jiti, which can execute modules in a VM context with its own intrinsics.Because
instanceof Promiseis false,hookablereturns synchronously without awaiting.build()proceeds tonitro.close()and the process tears down before thewriteFile(.../server.mjs)microtask chain resolves.Impact is not limited to Netlify — any Nitro 3 preset whose
compiledhook is async is at risk of silently skipping post-compile work under the same config-loading path.Suggested fix
Either (or both):
Promiseinstances.instanceofis unsafe across realms;typeof result?.then === 'function'is the standard thenable check.Promise.Happy to submit a PR against
hookableto accept thenables if that's the preferred path.Additional context
Confirmed fix locally by patching
hookable'scallHooks:With this patch,
server.mjsis written correctly on the next build and the hook body runs end-to-end. Reverted after verification.Nitro 2.13.1 with the same preset pattern works fine — either the older
hookableversion or a different config loader path didn't produce a cross-realm Promise, so theinstanceofcheck passed and the hook was awaited normally.Logs