diff --git a/packages/app/template-nextjs-overlay/spawndock/config.mjs b/packages/app/template-nextjs-overlay/spawndock/config.mjs index 6d2f414..cd90acc 100644 --- a/packages/app/template-nextjs-overlay/spawndock/config.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/config.mjs @@ -1,11 +1,23 @@ import { readFileSync } from "node:fs" import { resolve } from "node:path" +const readNumber = (value) => { + if (typeof value !== "string" || value.length === 0) { + return undefined + } + + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : undefined +} + export const readSpawndockConfig = (cwd = process.cwd()) => JSON.parse(readFileSync(resolve(cwd, "spawndock.config.json"), "utf8")) +export const resolveConfiguredLocalPort = (config, env = process.env) => + readNumber(env.SPAWNDOCK_PORT) ?? Number(config.localPort ?? 3000) + export const resolveLocalOrigin = (config) => - `http://127.0.0.1:${config.localPort ?? 3000}` + `http://127.0.0.1:${resolveConfiguredLocalPort(config)}` export const resolvePreviewOrigin = (config) => config.previewOrigin ?? "" diff --git a/packages/app/template-nextjs-overlay/spawndock/dev.mjs b/packages/app/template-nextjs-overlay/spawndock/dev.mjs index 00d62cd..556d377 100644 --- a/packages/app/template-nextjs-overlay/spawndock/dev.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/dev.mjs @@ -1,14 +1,28 @@ import { spawn } from "node:child_process" import { setTimeout } from "node:timers" -const scripts = [ - ["node", ["spawndock/next.mjs"]], - ["node", ["spawndock/tunnel.mjs"]], -] +import { readSpawndockConfig, resolveConfiguredLocalPort } from "./config.mjs" +import { findAvailablePort, waitForPort } from "./port.mjs" const children = [] +let shuttingDown = false + +const config = readSpawndockConfig() +const requestedLocalPort = resolveConfiguredLocalPort(config) +const localPort = await findAvailablePort(requestedLocalPort) +const sharedEnv = { + ...process.env, + SPAWNDOCK_PORT: String(localPort), +} + +if (localPort !== requestedLocalPort) { + console.warn( + `SpawnDock local port ${requestedLocalPort} is busy, using ${localPort} instead.` + ) +} const stopChildren = (signal) => { + shuttingDown = true for (const child of children) { if (!child.killed) { child.kill(signal ?? "SIGTERM") @@ -26,10 +40,10 @@ process.on("SIGTERM", () => { process.exit(0) }) -for (const [command, args] of scripts) { +const spawnChild = (command, args) => { const child = spawn(command, args, { cwd: process.cwd(), - env: process.env, + env: sharedEnv, stdio: "inherit", }) @@ -41,8 +55,18 @@ for (const [command, args] of scripts) { process.exit(code) } }) + + return child } +const nextChild = spawnChild("node", ["spawndock/next.mjs"]) + +await waitForPort(localPort, { + isCancelled: () => shuttingDown || nextChild.exitCode !== null, +}) + +spawnChild("node", ["spawndock/tunnel.mjs"]) + setTimeout(() => { console.log("SpawnDock dev session is ready.") }, 0) diff --git a/packages/app/template-nextjs-overlay/spawndock/next.mjs b/packages/app/template-nextjs-overlay/spawndock/next.mjs index 6cea45d..eac534e 100644 --- a/packages/app/template-nextjs-overlay/spawndock/next.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/next.mjs @@ -1,13 +1,27 @@ import { spawn } from "node:child_process" import readline from "node:readline" -import { readSpawndockConfig, resolveAllowedDevOrigins } from "./config.mjs" +import { + readSpawndockConfig, + resolveAllowedDevOrigins, + resolveConfiguredLocalPort, +} from "./config.mjs" +import { findAvailablePort } from "./port.mjs" const config = readSpawndockConfig() -const localPort = Number(config.localPort ?? 3000) +const requestedLocalPort = resolveConfiguredLocalPort(config) +const localPort = process.env.SPAWNDOCK_PORT + ? requestedLocalPort + : await findAvailablePort(requestedLocalPort) const allowedOrigins = resolveAllowedDevOrigins(config) const previewOrigin = config.previewOrigin ?? "" +if (localPort !== requestedLocalPort) { + console.warn( + `SpawnDock local port ${requestedLocalPort} is busy, using ${localPort} instead.` + ) +} + const child = spawn("pnpm", ["exec", "next", "dev", "-p", String(localPort)], { cwd: process.cwd(), env: { diff --git a/packages/app/template-nextjs-overlay/spawndock/port.mjs b/packages/app/template-nextjs-overlay/spawndock/port.mjs new file mode 100644 index 0000000..d158f1c --- /dev/null +++ b/packages/app/template-nextjs-overlay/spawndock/port.mjs @@ -0,0 +1,87 @@ +import { createServer, createConnection } from "node:net" + +const PORT_CHECK_TIMEOUT_MS = 500 +const PORT_READY_TIMEOUT_MS = 30_000 +const PORT_READY_POLL_MS = 200 +const MAX_PORT_ATTEMPTS = 20 + +const wait = (delayMs) => + new Promise((resolve) => { + setTimeout(resolve, delayMs) + }) + +export const isPortAvailable = (port) => + new Promise((resolve, reject) => { + const server = createServer() + + server.unref() + + server.once("error", (error) => { + if (error && typeof error === "object" && "code" in error) { + if (error.code === "EADDRINUSE" || error.code === "EACCES") { + resolve(false) + return + } + } + + reject(error) + }) + + server.listen(port, () => { + server.close(() => resolve(true)) + }) + }) + +export const findAvailablePort = async (preferredPort) => { + for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) { + const candidate = preferredPort + offset + if (await isPortAvailable(candidate)) { + return candidate + } + } + + throw new Error( + `Could not find a free local port starting from ${preferredPort} after ${MAX_PORT_ATTEMPTS} attempts` + ) +} + +export const isPortReachable = (port) => + new Promise((resolve) => { + const socket = createConnection({ + host: "127.0.0.1", + port, + }) + + socket.setTimeout(PORT_CHECK_TIMEOUT_MS) + + const finalize = (result) => { + socket.removeAllListeners() + socket.destroy() + resolve(result) + } + + socket.once("connect", () => finalize(true)) + socket.once("timeout", () => finalize(false)) + socket.once("error", () => finalize(false)) + }) + +export const waitForPort = async (port, options = {}) => { + const timeoutMs = options.timeoutMs ?? PORT_READY_TIMEOUT_MS + const pollMs = options.pollMs ?? PORT_READY_POLL_MS + const isCancelled = options.isCancelled ?? (() => false) + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + if (isCancelled()) { + throw new Error(`Local dev server exited before port ${port} became ready`) + } + + if (await isPortReachable(port)) { + return + } + + await wait(pollMs) + } + + throw new Error(`Timed out waiting for local dev server on port ${port}`) +}