ReviewOS

stacks/stx

SSG: a getStaticPaths that THREW is reported as one that does not exist, and the build still exits 0

#2116
Closed glennmichael123 opened this 8 days ago ·
8 days ago

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
conditionreported ascorrect?
1no <script server>"has no getStaticPaths"yes
2getStaticPaths absent"has no getStaticPaths"yes
3regex 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.
4the 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 it
  • error → a distinct message naming the route AND the error, and a non-zero exit (or an ssg.strict option, defaulting to strict, for projects that genuinely want a partial build)
  • ok with paths: [] → 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.

8 days ago

Fixed on main in d7759e8.

Static path discovery now distinguishes absent, intentional empty, read/parse/extraction failure, execution failure and invalid results. Failures enter SSGResult.failedCount/errors and the route-aware onError hook, preserving the cause and naming the route, template and phase. The existing build CLI therefore exits non-zero instead of publishing a green summary with its dynamic pages missing. Unknown page counts are recorded as one failed route, not invented page totals.

The truncating function-body regex is gone: Bun validates/transpiles TypeScript, then the shared position-preserving lexer and balanced delimiters extract the hook. Nested blocks, one-line declarations, arrow/const hooks and the documented defineStaticPaths initializer are covered. Missing hooks still warn; intentionally empty results are informational, not failures.

Evidence: packages/stx/test/ssg/static-paths-failure.test.ts has 26 passing cases, including actual build-CLI subprocesses, nested/TypeScript/regex/comment cases, absent/empty/error separation, malformed results and preserved read errors. The SSG/build cohort passed 334 tests / 0 failures across 31 files. Typecheck, lint/format checks and release preflight passed, including installed-package smoke, exports, budgets and 319 targeted tests.

Version: UNRELEASED after v0.2.674; no patch release cut. Hook evaluation remains isolated from other page declarations; this does not add imported/module-local closure resolution, and unsupported extraction is an explicit error. Other valid pages can still be written, but failed output must not be deployed. Craft is untouched.

Sign in to comment on this issue.