Skip to content

Netlify preset: compiled hook silently dropped, server.mjs never written (cross-realm Promise in hookable) #4203

@brandonroberts

Description

@brandonroberts

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

  1. hookable: match thenables, not just Promise instances. instanceof is unsafe across realms; typeof result?.then === 'function' is the standard thenable check.
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions