diff --git a/cli/commands/dev/port-fallback.test.ts b/cli/commands/dev/port-fallback.test.ts index 440882d3ab..33f1f36951 100644 --- a/cli/commands/dev/port-fallback.test.ts +++ b/cli/commands/dev/port-fallback.test.ts @@ -127,6 +127,30 @@ describe("cli/commands/dev/port-fallback", () => { }); }); + describe("npm build safety", () => { + it("never reaches the runtime through the binding dnt rewrites", async () => { + // dnt rewrites every bare `Deno.` access in the npm build to + // `dntShim.Deno.`, i.e. `@deno/shim-deno`. That shim implements + // its TCP listen as `net.createServer()` followed by an immediate read of + // `server._handle.fd`, and Deno's own `node:net` compat leaves `_handle` + // null at that point. So under a Deno-installed CLI the probe threw + // `TypeError: Cannot read properties of null (reading 'fd')` and + // `veryfront dev` died before the dev server could bind - while the very + // same package ran fine under Node, where the shim is not used. + // + // The runtime has to be reached through `getDenoRuntime()`, whose + // `Reflect.get(globalThis, "Deno")` dnt leaves alone. + const source = await Deno.readTextFile(new URL("./port-fallback.ts", import.meta.url)); + const rewrittenByDnt = source.match(/(? { it("recognises the address-in-use error the runtime actually throws", () => { const held = Deno.listen({ hostname: "127.0.0.1", port: 0 }); diff --git a/cli/commands/dev/port-fallback.ts b/cli/commands/dev/port-fallback.ts index 0d7894c1ff..3e9f16a1a6 100644 --- a/cli/commands/dev/port-fallback.ts +++ b/cli/commands/dev/port-fallback.ts @@ -14,7 +14,7 @@ import { LOCALHOST } from "veryfront/config"; import { PORT_IN_USE } from "veryfront/errors"; -import { isDeno } from "veryfront/platform"; +import { getDenoRuntime, isDeno } from "veryfront/platform"; /** How many consecutive ports to try before giving up. */ export const MAX_PORT_FALLBACK_ATTEMPTS = 10; @@ -33,12 +33,22 @@ export function isPortInUseError(error: unknown): boolean { message.includes("eaddrinuse") || message.includes("address already in use"); } -/** Binds `port` and releases it again, to see whether the dev server could have it. */ +/** + * Binds `port` and releases it again, to see whether the dev server could have it. + * + * The Deno namespace comes from `getDenoRuntime()` rather than the bare global: + * in the npm build dnt rewrites every bare `Deno` member access to + * `@deno/shim-deno`, whose TCP listen reads `server._handle.fd` straight after + * `net.createServer().listen()`. Deno's own `node:net` compat has not populated + * `_handle` by then, so a Deno-installed CLI crashed here with + * `Cannot read properties of null (reading 'fd')` before the dev server could + * bind. `getDenoRuntime()` reads the global reflectively, which dnt leaves alone. + */ export async function isPortAvailable(port: number): Promise { - if (isDeno) { + const denoRuntime = isDeno ? getDenoRuntime() : undefined; + if (denoRuntime) { try { - // @ts-ignore - Deno global - Deno.listen({ hostname: LOCALHOST.IPV4, port }).close(); + denoRuntime.listen({ hostname: LOCALHOST.IPV4, port }).close(); return true; } catch (error) { if (isPortInUseError(error)) return false;