Summary
In the SSG, four different outcomes of reading a route's getStaticPaths all collapse into null, and the caller reports every one of them as "has no getStaticPaths or returned empty paths". The build then exits 0.
Two of those four are real failures, and one of them is completely silent. A site can lose every dynamic page and still produce a green build.
Verified on 0.2.674 (latest published at time of writing); the code is unchanged from 0.2.176.
The code
dist/ssg.js, deminified:
async function extractStaticPaths(filePath) {
try {
const block = (await Bun.file(filePath).text())
.match(/<script\s+server\b[^>]*>([\s\S]*?)<\/script>/i)
if (!block) return null // (1) no <script server>
const body = block[1]
if (!body.includes('getStaticPaths')) return null // (2) not present
const fn = body.match(
/(?:export\s+)?(?:async\s+)?function\s+getStaticPaths\s*\([^)]*\)\s*\{([\s\S]*?)\n[\t ]*\}/)
if (!fn) return null // (3) REGEX MISS — silent
return await Function(`return (async function getStaticPaths() { ${fn[1]} })()`)()
}
catch (err) {
return console.error(`Error extracting static paths from ${filePath}:`, err), null // (4) it THREW
}
}
// caller
const result = await extractStaticPaths(route.filePath)
if (!result || result.paths.length === 0)
return console.warn(`Warning: Dynamic route ${route.pattern} has no getStaticPaths or returned empty paths`), urls| condition | reported as | correct? | |
|---|---|---|---|
| 1 | no <script server> | "has no getStaticPaths" | yes |
| 2 | getStaticPaths absent | "has no getStaticPaths" | yes |
| 3 | regex failed to extract the body | "has no getStaticPaths" | no — it is right there, line 2 already proved the string is present. No output at all. |
| 4 | the function threw | "has no getStaticPaths or returned empty paths" | no — it has one and it failed |
Case 3 is reachable in ordinary code, because ([\s\S]*?)\n[\t ]*\} stops at the first line that begins with optional whitespace and }. Any nested block closed at the start of a line truncates the body, and the extraction either fails or — worse — silently captures only part of the function:
export async function getStaticPaths() {
const rows = await db.judges.all()
if (!rows.length) {
return { paths: [] }
} // <- this line ends the match
return { paths: rows.map(r => ({ params: { id: String(r.id) } })) }
}Case 4 is any I/O inside getStaticPaths — a database, an API, a file read — which is the normal reason it exists.
What it cost us
CI builds our site without a database (the credentials are not in CI, by design). Every dynamic route's getStaticPaths threw ERR_MYSQL_CONNECTION_REFUSED, each was reported as the benign "has no getStaticPaths or returned empty paths", and the build exited 0 having written 51 pages instead of 2993 — no judge, courthouse, article or user page in the artifact.
Nothing in the build said anything was wrong. We only caught it because our own conformance gate independently probes the database and reports it unreachable. A project without that second check would ship the shell.
Suggested fix
Distinguish the cases at the point where they are still distinguishable. Something like:
// → { kind: 'absent' } | { kind: 'error', error } | { kind: 'ok', paths }and then:
absent→ the current warning, which is accurate for iterror→ a distinct message naming the route AND the error, and a non-zero exit (or anssg.strictoption, defaulting to strict, for projects that genuinely want a partial build)okwithpaths: []→ its own message: a route that answered, with nothing to build yet, is a legitimate and common state and reads very differently from the other two
Case 3 deserves separating regardless of the rest: if getStaticPaths is present in the source but the body cannot be extracted, that is a parse failure in stx and should say so loudly rather than pretending the function is absent. A real parse would remove the class of bug entirely.
The weaker, still valuable version: keep the current control flow, but make the caller's warning say which of the three happened.