Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | #!/usr/bin/env node
'use strict'
/**
* start-previewer
*
* Starts the Next.js previewer (`next dev`) as a DETACHED background server and
* returns only once it is actually reachable, so the human visual gate never
* gets dead URLs. The server keeps running after this script exits; tear it
* down with stop-previewer.
*
* Assumes previewer data is already built (verify-preview does that). It does
* NOT rebuild — it only brings the server up and waits for readiness.
*
* Idempotent: if a live server is already recorded for this release on the same
* port, it is reused instead of starting a second one. If a live server is
* recorded on a different port, it fails with `previewerRunning`; run
* stop-previewer first.
*
* Usage:
* node .claude/skills/icons-release/scripts/start-previewer.js --release R264 [--port 3000] [--timeout 120000]
*
* Output: { success, step, data: { url, port, pid, logFile, reused } }
*/
const fs = require('fs')
const path = require('path')
const http = require('http')
const { spawn } = require('child_process')
const { succeed, failWith, run, log } = require('./lib/output')
const { parseArgs } = require('./lib/args')
const { parseRelease, WORKFLOW_ROOT } = require('./lib/workset')
const STEP = 'start-previewer'
function resolveNextCli() {
// Yarn 4 does not put `next` on PATH for this workspace. The CLI lives in
// the package's node_modules or the SDS repo-root hoist.
const candidates = [
path.join(process.cwd(), 'node_modules', 'next', 'dist', 'bin', 'next'),
path.join(process.cwd(), '..', '..', 'node_modules', 'next', 'dist', 'bin', 'next')
]
const found = candidates.find(candidate => fs.existsSync(candidate))
if (!found) {
failWith(
STEP,
'nextNotFound',
'Could not find next CLI under node_modules. Run yarn install from the SDS repo root.'
)
}
return found
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
function ping(url) {
return new Promise(resolve => {
const req = http.get(url, res => {
res.resume()
resolve(res.statusCode >= 200 && res.statusCode < 500)
})
req.on('error', () => resolve(false))
req.setTimeout(2500, () => {
req.destroy()
resolve(false)
})
})
}
function isAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch (_) {
return false
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (!args.release) failWith(STEP, 'missingArg', '--release is required (e.g. --release R264)')
const { release } = parseRelease(args.release)
const port = String(args.port || '3000')
const timeoutMs = Number(args.timeout || 120000)
const url = `http://localhost:${port}`
const location = path.join(WORKFLOW_ROOT, release)
fs.mkdirSync(location, { recursive: true })
const pidFile = path.join(location, 'previewer.pid')
const logFile = path.join(location, 'previewer.log')
// Reuse a live server already recorded for this release/port. A live server
// that can't be reused (e.g. a different port) is refused rather than
// replaced, so its record is never lost and stop-previewer can still reach it.
if (fs.existsSync(pidFile)) {
const prev = JSON.parse(fs.readFileSync(pidFile, 'utf8'))
if (isAlive(prev.pid)) {
if (prev.port === port && (await ping(url))) {
succeed(STEP, { url, port, pid: prev.pid, logFile, reused: true })
return
}
failWith(
STEP,
'previewerRunning',
`A previewer for ${release} is still running (pid ${prev.pid}, port ${prev.port}). Run stop-previewer --release ${release} first.`
)
}
fs.rmSync(pidFile, { force: true }) // stale record
}
const nextCli = resolveNextCli()
log(`> starting previewer: node ${nextCli} dev -p ${port} (logs -> ${logFile})`)
const out = fs.openSync(logFile, 'a')
const child = spawn(process.execPath, [nextCli, 'dev', '-p', port], {
detached: true,
stdio: ['ignore', out, out],
cwd: process.cwd(),
env: process.env
})
let exitedEarly = false
child.on('exit', code => {
exitedEarly = true
log(`previewer process exited early with code ${code}`)
})
child.unref()
fs.writeFileSync(pidFile, JSON.stringify({ pid: child.pid, port }) + '\n')
// Progress feedback so the wait never looks frozen. Goes to stderr only
// (stdout is reserved for the single JSON object). Animates in a TTY;
// prints periodic lines when piped.
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const started = Date.now()
let i = 0
const tty = process.stderr.isTTY
const tick = () => {
const secs = Math.round((Date.now() - started) / 1000)
const msg = `${frames[i++ % frames.length]} waiting for previewer at ${url} … ${secs}s`
if (tty) process.stderr.write(`\r${msg} `)
else if (i % 4 === 1) process.stderr.write(msg + '\n') // ~every 6s when piped
}
const clearLine = () => {
if (tty) process.stderr.write('\r' + ' '.repeat(60) + '\r')
}
// Poll until the server answers or we time out / it dies.
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (exitedEarly) {
clearLine()
const tail = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8').slice(-800) : ''
fs.rmSync(pidFile, { force: true })
failWith(STEP, 'previewerExited', `next dev exited before becoming ready. Log tail:\n${tail}`)
}
if (await ping(url)) {
clearLine()
log(`✓ previewer ready at ${url}`)
succeed(STEP, { url, port, pid: child.pid, logFile, reused: false })
return
}
tick()
await sleep(1500)
}
clearLine()
// Timed out — don't leave an orphan we can't account for.
try {
process.kill(-child.pid)
} catch (_) {
try {
process.kill(child.pid)
} catch (_) {}
}
fs.rmSync(pidFile, { force: true })
const tail = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8').slice(-800) : ''
failWith(STEP, 'startTimeout', `Previewer not reachable at ${url} within ${timeoutMs}ms. Log tail:\n${tail}`)
}
run(STEP, main)
|