Skip to content
Merged
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
14 changes: 13 additions & 1 deletion packages/app/template-nextjs-overlay/spawndock/config.mjs
Original file line number Diff line number Diff line change
@@ -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 ?? ""
Expand Down
36 changes: 30 additions & 6 deletions packages/app/template-nextjs-overlay/spawndock/dev.mjs
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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",
})

Expand All @@ -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)
18 changes: 16 additions & 2 deletions packages/app/template-nextjs-overlay/spawndock/next.mjs
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
87 changes: 87 additions & 0 deletions packages/app/template-nextjs-overlay/spawndock/port.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
}