Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions cli/commands/dev/port-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,73 @@ describe("cli/commands/dev/port-fallback", () => {

assertEquals(await isPortAvailable(freePort), true);
});

it("probes without going through the ambient Deno namespace", async () => {
// Stand in the failure the npm build hits under Deno: dnt swaps the
// ambient namespace for @deno/shim-deno, whose `listen` throws on a null
// `server._handle`. The probe must answer correctly without it.
const nativeListen = Deno.listen;
const held = nativeListen({ hostname: "127.0.0.1", port: 0 });
const heldPort = (held.addr as Deno.NetAddr).port;
const freeListener = nativeListen({ hostname: "127.0.0.1", port: 0 });
const freePort = (freeListener.addr as Deno.NetAddr).port;
freeListener.close();

Object.defineProperty(Deno, "listen", {
configurable: true,
writable: true,
value: () => {
throw new TypeError("Cannot read properties of null (reading 'fd')");
},
});

try {
assertEquals(await isPortAvailable(heldPort), false);
assertEquals(await isPortAvailable(freePort), true);
} finally {
Object.defineProperty(Deno, "listen", {
configurable: true,
writable: true,
value: nativeListen,
});
held.close();
}
});
});

describe("npm build safety", () => {
it("never reaches the runtime through the binding dnt rewrites", async () => {
// dnt rewrites every bare `Deno.<member>` access in the npm build to
// `dntShim.Deno.<member>`, 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 test above proves the probe survives a poisoned ambient namespace.
// This one guards the whole file, including paths that test never runs:
// any bare `Deno.` member access reintroduced anywhere here is a rewrite
// target. Reach the runtime through `getDenoRuntime()` instead, whose
// `Reflect.get(globalThis, "Deno")` dnt leaves alone.
const source = await Deno.readTextFile(new URL("./port-fallback.ts", import.meta.url));
// dnt rewrites code, not prose, and the fix's own doc comment has to be
// free to name `Deno.listen` as the thing it stopped calling. Strip
// comments first - `(?<![:\\])` keeps the `//` of a `https://` inside one
// from ending it early. String literals are left in, so a `"Deno.foo"`
// would be a false positive: it fails towards rewording, never silence.
const code = source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(?<![:\\])\/\/.*$/gm, "");
const rewrittenByDnt = code.match(/(?<![.\w$])Deno\.\w+/g) ?? [];

assertEquals(
rewrittenByDnt,
[],
`dnt would rewrite ${rewrittenByDnt.join(", ")} to the broken @deno/shim-deno namespace`,
);
});
});

describe("isPortInUseError", () => {
Expand Down
21 changes: 8 additions & 13 deletions cli/commands/dev/port-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

import { LOCALHOST } from "veryfront/config";
import { PORT_IN_USE } from "veryfront/errors";
import { isDeno } from "veryfront/platform";

/** How many consecutive ports to try before giving up. */
export const MAX_PORT_FALLBACK_ATTEMPTS = 10;
Expand All @@ -33,19 +32,15 @@ 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.
*
* One `node:net` path serves every runtime. A bare `Deno.listen` cannot: the
* npm build resolves bare `Deno` to dnt's @deno/shim-deno even when the CLI
* runs on Deno, and that shim reads `server._handle.fd` synchronously, which
* Deno leaves null - the crash that stopped `veryfront dev` binding at all.
*/
export async function isPortAvailable(port: number): Promise<boolean> {
if (isDeno) {
try {
// @ts-ignore - Deno global
Deno.listen({ hostname: LOCALHOST.IPV4, port }).close();
return true;
} catch (error) {
if (isPortInUseError(error)) return false;
throw error;
}
}

const net = await import("node:net");
return await new Promise<boolean>((resolve, reject) => {
const server = net.createServer();
Expand Down