Skip to content

Retro: nine cascading bugs between "fresh app + ./buddy dev" and "API endpoints actually work" #1837

@glennmichael123

Description

@glennmichael123

Filing this as a retrospective for anyone debugging the same surface. The user-visible symptom was "every endpoint of a fresh Stacks app returns 404", but the trail had nine distinct root causes spanning four repositories. Each one masked the next. Listing them in the order they had to be uncovered, with the fix that landed.

TL;DR

# Layer Root cause Fix Where
1 Scaffold routes/api.ts imported route from @stacksjs/bun-router, which only exports router (lowercase). route was undefined; every route.post(...) threw at module-load time. Import from @stacksjs/router. training repo
2 Stacks The above error was swallowed by log.error in importRoutes(). Silent route-loader failures masked everything downstream. Surface the failure. stacks (#1835)
3 Stacks preloader.ts skipped auto-imports for any subprocess where args.length === 0 — including the spawned bun --watch dev/api.ts. Action files calling new Action({...}) blew up because Action wasn't on globalThis. Export loadAutoImports, refactor skip condition. stacks (#1835)
4 rpx /api/* pathRewrite used stripPrefix: false, forwarding /api/auth/login verbatim to an API server that registered routes flat (no /api prefix). stripPrefix: true — eventually dropped entirely in favor of the API subdomain. stacks
5 dtsx bun-plugin-dtsx only emitted entrypoint .d.ts files. The barrel re-exports inside (export * from './router') pointed at sibling .d.ts files that were never written. Published @stacksjs/bun-router@0.0.5 shipped with effectively empty types — editors saw any everywhere, hence TS7034 on route and TS2305 on EnhancedRequest/Router/etc. Replaced bun-plugin-dtsx with tsc -p tsconfig.build.json in bun-router's build pipeline; underlying dtsx behavior tracked separately at stacksjs/dtsx#3090. bun-router aefe808
6 Architecture @stacksjs/router had grown to 2066 LOC of parallel routing — route singleton, runWithRequest, url(), registry loader — duplicating features bun-router now ships natively (string action handlers, file-based routing, named routes). Two route instances, two ways to import everything, four files of ambiguity. Push the genuinely-missing pieces (AsyncLocalStorage request context, url(name, params)) upstream into bun-router; collapse @stacksjs/router to a thin shim (-705 LOC, -34%). bun-router aefe808, stacksjs/bun-router#874; training repo; stacks #1836 (deprecation tracker)
7 stx No build-time import.meta.env.* substitution. Stores written in plain .ts couldn't read public env vars at all. The conventional Vite pattern (const url = import.meta.env.STX_PUBLIC_API_URL) just produced undefined in the browser. Wire getPublicEnvDefine() into all five client-bundle sites in stx (Bun.build + Bun.Transpiler), so any STX_PUBLIC_* env gets statically inlined. stx c85b28d2ac
8 bun-router requestMacroRegistry defined bearerToken(), input(), has(), missing(), filled(), only(), except(), wantsJson(), ip(), userAgent(), header(), etc. — but applyMacros() was never called from the live enhanceRequest(). Macros only existed for callers who manually invoked RequestHelpers.withMacros(req). Real middleware crashed with TypeError: request.bearerToken is not a function. Call RequestWithMacros.applyMacros(enhancedReq) inside enhanceRequest(), before the cookie utility (so the cookies macro doesn't clobber the get/set/delete object form). bun-router 3a9e0a2
9 Both response.json(data, 401) silently dropped the status — the second arg was an options object, the number was discarded, response came back HTTP 200 with an error body. Client code branching on res.ok saw "success" and called auth.login(undefined, undefined), navigated home, then the next protected request had no token, the auth middleware threw HttpError(401), and the middleware error handler mapped that to 500 because it checked err.statusCode while HttpError exposes err.status. Two fixes: (a) response.json accepts a number as the second arg (Hono/Express convention); (b) middleware error handler reads err.status ?? err.statusCode. bun-router 79c6066; training repo

How they compounded (concrete chain that produced "wrong creds → home page")

  1. User submits wrong credentials.
  2. LoginAction returns response.json({error: 'Invalid credentials'}, 401).
  3. #9a drops the 401. API returns HTTP 200 with {error: '...'}.
  4. Client fetch sees res.ok === true, calls auth.login(data.user, data.token) with both undefined.
  5. SPA navigates to /.
  6. Home page issues a protected fetch.
  7. Auth middleware: request.bearerToken() — would crash with chore(deps): update devdependency vitest to ^0.10.2 #8 if untouched, or returns null post-fix.
  8. Middleware throws HttpError(401, 'Unauthorized').
  9. #9b misroutes the throw — error handler checks statusCode (absent), falls through to generic 500 path.
  10. User sees a 500 error page for what should have been a 401, in a session that should never have started.

Three of the nine bugs (8, 9a, 9b) had to land before the auth flow could fail correctly. The other six had to land before the API was reachable at all.

Cross-repo fix matrix

Repo Commits / issues
stacksjs/stacks #1835 (recommendations for #1#4), #1836 (deprecate @stacksjs/router)
stacksjs/bun-router aefe808 (request context + url() + tsc-based d.ts pipeline), 3a9e0a2 (macros applied), 79c6066 (status number), stacksjs/bun-router#874 (deprecation proposal — closed)
stacksjs/stx c85b28d2ac (import.meta.env.STX_PUBLIC_* substitution)
stacksjs/dtsx stacksjs/dtsx#3090 (barrel-entrypoint silent breakage — open)
stacksjs/bun-plugin-dtsx stacksjs/bun-plugin-dtsx#3976 (mirror — open)

Blocked on (release state)

The framework-side migration tracked in #1836 cannot fully land until the following commits ship as published versions, because Projects/stacks (where the 268 import sites live) consumes these packages from npm rather than git:

Package Required commit Currently in published version? Blocks
@stacksjs/bun-router 3a9e0a2 — apply request macros (bearerToken, input, has, etc.) on every request ❌ on main, not in 0.0.7 The Stacks Auth middleware (storage/framework/defaults/app/Middleware/Auth.ts) calls request.bearerToken() — without this commit it throws TypeError. Any fresh install of @stacksjs/bun-router@0.0.7 is broken for auth.
@stacksjs/bun-router 79c6066 — accept status number as 2nd arg to response.json() ❌ on main, not in 0.0.7 All ~30 Action files in this repo's app/Actions/** (and equivalents in user apps) use response.json(data, 401) shorthand. Without this commit those return HTTP 200 — wrong-credentials login appears to succeed.
@stacksjs/stx c85b28d2ac — build-time import.meta.env.STX_PUBLIC_* substitution ❌ on main, not in 0.2.21 Stores and other client-side .ts files can't read public env vars. Any cross-subdomain SPA pattern (STX_PUBLIC_API_URL-style config) is dead in the water.

Action items before the finale:

  1. Release @stacksjs/bun-router@0.0.8 (or whatever the next version is) containing 3a9e0a2 and 79c6066.
  2. Release @stacksjs/stx@0.2.22+ containing c85b28d2ac.
  3. Bump these in Projects/stacks package.json(s).
  4. Then proceed with the @stacksjs/router shim collapse upstream, mirroring what landed in our test repo (-705 LOC, no consumer rewrites needed).

Without those releases, the migration appears to work in dev (because local linking masks the version gap) but breaks every fresh bun install.

Other open issues (non-blocking but worth landing)

Verification checklist for a fresh app

After all fixes land, a clean bun create stacks my-app && cd my-app && ./buddy dev should pass:

  • routes/api.ts template imports route from @stacksjs/router
  • https://<domain>/health returns 200 (frontend reachable)
  • https://api.<domain>/health returns 200 (API reachable on subdomain)
  • No swallowed log.error from route loading — failures fail loudly
  • Editor shows real types for route, Router, EnhancedRequest, etc. (no any)
  • bun -e "import { url, request, runWithRequest } from '@stacksjs/bun-router'" resolves
  • In a route handler, request.bearerToken() is a function
  • In a store, import.meta.env.STX_PUBLIC_API_URL is the literal string at runtime
  • response.json({err:'x'}, 401) returns HTTP 401
  • throw new HttpError(401, '...') from middleware returns HTTP 401, not 500
  • Wrong login credentials → form shows error inline, no navigation
  • @stacksjs/router's src/ is a thin shim (≤ ~1400 LOC), not 2k+

Lessons (for the framework)


Footnote — current local link state

While the unreleased commits land in published versions, the training repo I used for this retro keeps @stacksjs/bun-router bun link'd directly against the local source so end-to-end testing exercises the real fixes:

training/node_modules/@stacksjs/bun-router            → ../../../bun-router/packages/bun-router
training/storage/framework/core/router/node_modules/@stacksjs/bun-router  → /Users/…/bun-router/packages/bun-router

(Two symlinks because the nested workspace at storage/framework/core/router/ has its own node_modules/@stacksjs/bun-router pinned to ^0.0.5. Couldn't bun link the nested one cleanly because its workspace deps don't resolve in that context — symlinked it manually instead.)

Same shape applies to @stacksjs/stx, which had four nested copies (bun-plugin-stx/, bunpress/, framework/core/browser/, framework/core/composables/) that all needed manual symlinks for the import.meta.env substitution to be live everywhere.

Kinks worth addressing once we drop the link:

  • Workspace nested-deps cause bun link to fail when the nested package can't resolve its sibling @stacksjs/* workspaces in the link context. We worked around with raw ln -s, but a real bun link should handle this.
  • Per-app bun link-management is fragile — multiple symlinks per nested copy means every fresh bun install blows them away and they have to be reapplied. A canonical linked-packages.json or similar would help.
  • Pin-version skew between the published @stacksjs/bun-router@0.0.7 (in nested workspace package.json) and the linked HEAD silently masks bugs that only surface for users installing from npm — exactly the trap this retro section warns about.

None blocks landing the retro's other action items, but all are worth a discussion when the framework upgrade work begins.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions