You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
/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.
@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%).
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.
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).
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.
#9b misroutes the throw — error handler checks statusCode (absent), falls through to generic 500 path.
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)
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.
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:
Release @stacksjs/bun-router@0.0.8 (or whatever the next version is) containing 3a9e0a2 and 79c6066.
Two ways to import the same thing is the bug.Dependency Dashboard #1 happened because bun-router and @stacksjs/router both seemed to expose route (the latter via the wildcard re-export). Collapsing to one canonical export removes the trap entirely.
Macro registries that aren't applied are worse than no macros.chore(deps): update devdependency vitest to ^0.10.2 #8 was a real foot-gun — TypeScript types said bearerToken() exists, runtime said it didn't. Either apply on every request or remove the registry.
Status-as-options-object is non-standard.chore(deps): update all non-major dependencies #9 cost a full debugging cycle for what should have been a one-line action handler. response.json(data, 401) is the convention everywhere else; the framework should match.
Footnote — current local link state
While the unreleased commits land in published versions, the training repo I used for this retro keeps @stacksjs/bun-routerbun link'd directly against the local source so end-to-end testing exercises the real fixes:
(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.
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
routes/api.tsimportedroutefrom@stacksjs/bun-router, which only exportsrouter(lowercase).routewasundefined; everyroute.post(...)threw at module-load time.@stacksjs/router.log.errorinimportRoutes(). Silent route-loader failures masked everything downstream.preloader.tsskipped auto-imports for any subprocess whereargs.length === 0— including the spawnedbun --watch dev/api.ts. Action files callingnew Action({...})blew up becauseActionwasn't onglobalThis.loadAutoImports, refactor skip condition./api/*pathRewrite usedstripPrefix: false, forwarding/api/auth/loginverbatim to an API server that registered routes flat (no/apiprefix).stripPrefix: true— eventually dropped entirely in favor of the API subdomain.bun-plugin-dtsxonly emitted entrypoint.d.tsfiles. The barrel re-exports inside (export * from './router') pointed at sibling.d.tsfiles that were never written. Published@stacksjs/bun-router@0.0.5shipped with effectively empty types — editors sawanyeverywhere, henceTS7034onrouteandTS2305onEnhancedRequest/Router/etc.bun-plugin-dtsxwithtsc -p tsconfig.build.jsoninbun-router's build pipeline; underlying dtsx behavior tracked separately at stacksjs/dtsx#3090.aefe808@stacksjs/routerhad grown to 2066 LOC of parallel routing —routesingleton,runWithRequest,url(), registry loader — duplicating features bun-router now ships natively (string action handlers, file-based routing, named routes). Tworouteinstances, two ways to import everything, four files of ambiguity.url(name, params)) upstream into bun-router; collapse@stacksjs/routerto a thin shim (-705 LOC, -34%).aefe808, stacksjs/bun-router#874; training repo; stacks #1836 (deprecation tracker)import.meta.env.*substitution. Stores written in plain.tscouldn't read public env vars at all. The conventional Vite pattern (const url = import.meta.env.STX_PUBLIC_API_URL) just producedundefinedin the browser.getPublicEnvDefine()into all five client-bundle sites in stx (Bun.build + Bun.Transpiler), so anySTX_PUBLIC_*env gets statically inlined.c85b28d2acrequestMacroRegistrydefinedbearerToken(),input(),has(),missing(),filled(),only(),except(),wantsJson(),ip(),userAgent(),header(), etc. — butapplyMacros()was never called from the liveenhanceRequest(). Macros only existed for callers who manually invokedRequestHelpers.withMacros(req). Real middleware crashed withTypeError: request.bearerToken is not a function.RequestWithMacros.applyMacros(enhancedReq)insideenhanceRequest(), before the cookie utility (so the cookies macro doesn't clobber the get/set/delete object form).3a9e0a2response.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 onres.oksaw "success" and calledauth.login(undefined, undefined), navigated home, then the next protected request had no token, the auth middleware threwHttpError(401), and the middleware error handler mapped that to 500 because it checkederr.statusCodewhileHttpErrorexposeserr.status.response.jsonaccepts a number as the second arg (Hono/Express convention); (b) middleware error handler readserr.status ?? err.statusCode.79c6066; training repoHow they compounded (concrete chain that produced "wrong creds → home page")
LoginActionreturnsresponse.json({error: 'Invalid credentials'}, 401).401. API returns HTTP 200 with{error: '...'}.res.ok === true, callsauth.login(data.user, data.token)with both undefined./.request.bearerToken()— would crash with chore(deps): update devdependency vitest to ^0.10.2 #8 if untouched, or returnsnullpost-fix.HttpError(401, 'Unauthorized').statusCode(absent), falls through to generic 500 path.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
stacksjs/stacks@stacksjs/router)stacksjs/bun-routeraefe808(request context +url()+ tsc-based d.ts pipeline),3a9e0a2(macros applied),79c6066(status number), stacksjs/bun-router#874 (deprecation proposal — closed)stacksjs/stxc85b28d2ac(import.meta.env.STX_PUBLIC_*substitution)stacksjs/dtsxstacksjs/bun-plugin-dtsxBlocked 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:@stacksjs/bun-router3a9e0a2— apply request macros (bearerToken,input,has, etc.) on every requestmain, not in0.0.7Authmiddleware (storage/framework/defaults/app/Middleware/Auth.ts) callsrequest.bearerToken()— without this commit it throwsTypeError. Any fresh install of@stacksjs/bun-router@0.0.7is broken for auth.@stacksjs/bun-router79c6066— accept status number as 2nd arg toresponse.json()main, not in0.0.7app/Actions/**(and equivalents in user apps) useresponse.json(data, 401)shorthand. Without this commit those return HTTP 200 — wrong-credentials login appears to succeed.@stacksjs/stxc85b28d2ac— build-timeimport.meta.env.STX_PUBLIC_*substitutionmain, not in0.2.21.tsfiles 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:
@stacksjs/bun-router@0.0.8(or whatever the next version is) containing3a9e0a2and79c6066.@stacksjs/stx@0.2.22+containingc85b28d2ac.Projects/stackspackage.json(s).@stacksjs/routershim 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)
.d.ts. Worked around in bun-router (usingtsc); other Stacks packages that usebun-plugin-dtsxlikely hit the same trap. Not strictly blocking the finale, but easy way to regress someone else's package../buddy dev— four cascading issues in router/preloader/rpx wiring #1835 — the original four-cause API-404 report. Recommendations are now in the issue body. Fixes need to be applied upstream so a fresh Stacks app generated from the scaffold actually works without local patches.Verification checklist for a fresh app
After all fixes land, a clean
bun create stacks my-app && cd my-app && ./buddy devshould pass:routes/api.tstemplate importsroutefrom@stacksjs/routerhttps://<domain>/healthreturns 200 (frontend reachable)https://api.<domain>/healthreturns 200 (API reachable on subdomain)log.errorfrom route loading — failures fail loudlyroute,Router,EnhancedRequest, etc. (noany)bun -e "import { url, request, runWithRequest } from '@stacksjs/bun-router'"resolvesrequest.bearerToken()is a functionimport.meta.env.STX_PUBLIC_API_URLis the literal string at runtimeresponse.json({err:'x'}, 401)returns HTTP 401throw new HttpError(401, '...')from middleware returns HTTP 401, not 500@stacksjs/router'ssrc/is a thin shim (≤ ~1400 LOC), not 2k+Lessons (for the framework)
bun-routerand@stacksjs/routerboth seemed to exposeroute(the latter via the wildcard re-export). Collapsing to one canonical export removes the trap entirely.bearerToken()exists, runtime said it didn't. Either apply on every request or remove the registry.response.json(data, 401)is the convention everywhere else; the framework should match.Footnote — current local link state
While the unreleased commits land in published versions, the training repo I used for this retro keeps
@stacksjs/bun-routerbun link'd directly against the local source so end-to-end testing exercises the real fixes:(Two symlinks because the nested workspace at
storage/framework/core/router/has its ownnode_modules/@stacksjs/bun-routerpinned to^0.0.5. Couldn'tbun linkthe 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 theimport.meta.envsubstitution to be live everywhere.Kinks worth addressing once we drop the link:
bun linkto fail when the nested package can't resolve its sibling@stacksjs/*workspaces in the link context. We worked around with rawln -s, but a realbun linkshould handle this.bun link-management is fragile — multiple symlinks per nested copy means every freshbun installblows them away and they have to be reapplied. A canonicallinked-packages.jsonor similar would help.@stacksjs/bun-router@0.0.7(in nested workspacepackage.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.