Skip to content

feat(sdk): server error capture helpers for full-stack apps - #325

Open
egidijusj wants to merge 2 commits into
base44:mainfrom
egidijusj:sdk-server-error-capture
Open

egidijusj wants to merge 2 commits into
base44:mainfrom
egidijusj:sdk-server-error-capture

Conversation

@egidijusj

@egidijusj egidijusj commented Oct 13, 2026 •

Copy link
Copy Markdown

Why

Base44 alerts an app's owner when the app fails in production. A full-stack (TanStack Start) app runs its server code (server functions and server routes) in its own Cloudflare Worker. When that code fails, Base44 learns almost nothing:

  • A server function that throws: TanStack catches the error, sends the client only its message, and answers HTTP 200. To Base44, the request succeeded.
  • A server route that throws: the error escapes the framework and shows up as a generic Worker crash, with no name, message or stack.

Only the Worker sees the real error, so the Worker has to report it. These helpers put the report in a response header, X-B44-Function-Error. It holds the error's name, its message and its raw stack frames, which are positions in the built Worker modules. Base44 maps those back to the app's source with the Worker's source maps. Base44's serving layer reads the header and removes it before the response reaches the visitor, so visitors get exactly what they got before.

Capture is opt-in per request. Serving sends X-B44-Capture-Errors: 1 when it wants a report, and without it the helpers do nothing. They never throw into the request: if something goes wrong inside them, they behave as if capture were off.

Why the SDK. Every full-stack app already depends on @base44/sdk. Keeping the header format, size limits and stack parsing in one versioned package lets the platform change them with a version bump, instead of editing each app's code. The helpers don't depend on TanStack. The app template wraps them in TanStack middleware and handles the TanStack-specific cases itself: redirects, notFound and thrown Responses.

getAnonymousVisitorId() belongs to the same feature. The template's client sends the visitor's existing anonymous id on server-function calls, so Base44 can count how many signed-out visitors an error affects.

No new runtime dependencies, and no existing export changes.

Exported

recordServerError(request: Request, error: unknown, options?: RecordServerErrorOptions): void
annotateServerResponse(request: Request, response: Response): Promise<Response>
serverErrorResponse(request: Request, error: unknown): Response
getAnonymousVisitorId(): string | null

interface RecordServerErrorOptions { fn?: string }
  • recordServerError keeps the error's name, message and raw stack in a module-level WeakMap keyed by the Request. A later call for the same request replaces the earlier record. fn is dropped unless it matches ^[A-Za-z0-9_$.:/-]{1,200}$.
  • annotateServerResponse:
    • With a recorded error, it reports kind: "uncaught": at any status when the record has fn, or at a 500 when it doesn't.
    • Otherwise, at 500 to 599, it reports kind: "response". The message comes from the JSON body (§16.3.2's rules: JSON media types only, at most 2 KB from a clone(), 50 ms deadline, then cancel), or else from the last recorded error. The name and frames come only from a recorded error whose message matches.
    • It leaves alone a 101 or WebSocket response, and TanStack's page-route 500 to a non-HTML Accept.
    • It sets the header in place. If the headers are immutable, it returns new Response(response.body, response), so the body still streams.
  • serverErrorResponse returns a 500 with a text/plain body of Internal Server Error, plus the header when capture is on.
  • getAnonymousVisitorId returns the id the SDK already stores and sends as X-Base44-Anonymous-Id (getAnalyticsSessionId(), under base44_analytics_session_id in localStorage). It returns null when there is no window. No second id.

Header, as the reader expects:

  • v: 2; name at most 64 characters.
  • message: toWellFormed() (with a fallback), then cut to 500 UTF-8 bytes on a code point.
  • frames: at most 30 raw V8 frames, in stack order: named, anonymous and async frames. Eval and native frames are skipped; file:// and leading / are stripped. A frame that fails the file pattern, contains .., or has a position outside 1 to 2³¹−1 is dropped.
  • Everything above ASCII is escaped as \uXXXX. At most 3,072 bytes: frames are dropped from the end until it fits. Escaping can still push a 500-byte message past the budget (control characters become 6 bytes each), so if dropping every frame isn't enough, the message is cut further, to the most code points that fit, rather than losing the report.

Body of serverErrorResponse: plain-text Internal Server Error. It's the reason phrase, so it says nothing about the app and is the same with capture on or off: the platform's own 500 page stands in for it in production anyway. Not JSON, so the body rules can never read a message back out of it. It also console.errors the error, since that error used to escape the Worker and reach its logs, and swallowing it into a 500 would otherwise drop it.

What differs from apper #30612's draft

  1. functionId doesn't exist in the pinned TanStack versions (@tanstack/start-client-core 1.170.15, under react-start 1.168.36): a function middleware gets serverFnMeta: { id, name, filename }. With functionId, fn is always undefined, so a server function's error, which TanStack answers with a 200, is never reported. The e2e caught this. Change the function middleware (template, and MIDDLEWARE_SOURCE in #30614's codemod) to:
    .server(async ({ next, serverFnMeta }) => {
      ...
      recordServerError(getRequest(), error, { fn: serverFnMeta?.name || serverFnMeta?.id });
    name is the function's variable name (sendInvoice) and always matches the fn pattern. id is a 64-hex hash, the fallback. filename can contain (group) parentheses, which the pattern rejects, so it isn't used.
  2. A recorded error without fn at a status other than 500 isn't written as uncaught, because the reader would reject it. At 501 to 599 it's reported as response, using the recorded message; at any other status, nothing is written.
  3. serverErrorResponse logs the error (above).

Signatures and names are otherwise as the draft uses them, including getAnonymousVisitorId() in auth-middleware.js.

Tests

  • Vitest (tests/unit/server-errors.test.ts, 128 tests):

    • activation on and off;
    • WeakMap isolation;
    • every body field and its order, both SSO shapes, text, HTML, oversized and invalid bodies;
    • a streamed body that hits the deadline and still streams afterwards;
    • mutable headers, plus fetch() and Response.redirect() immutable ones;
    • 101 and WebSocket responses, and the non-HTML-Accept page route;
    • Unicode, lone surrogates (native and fallback), and the 500-byte cut;
    • frame parsing (23 shapes), the 30-frame cap and size fitting, including a message of 500 control characters;
    • fn validation;
    • hostile requests, errors, responses and bodies, none of which throw;
    • getAnonymousVisitorId in Node and in a stubbed browser.

    Every header a test produces is also run through a port of the backend reader at the response's status.

  • Contract (tests/fixtures/function-error-header-v2.json): 40 headers the reader must accept, with the fields it keeps, or reject. tests/unit/server-errors-contract.test.ts pins the TS port to it. check-function-error-contract.py runs the same fixture through apper's real read_function_error(..., version=2): 40/40 match against #30606's head. Apper can run it in CI with python check-function-error-contract.py <apper>, or copy the fixture.

  • End to end (tests/worker-e2e/, new CI job server-error-capture-e2e): a minimal TanStack Start app on @cloudflare/vite-plugin. Versions are the template's lockfile pins: react-start 1.168.36, start-server-core 1.169.19, router-core 1.171.16, @cloudflare/vite-plugin 1.54.6, vite 8.2.0, rolldown 1.2.3, workerd 1.20260908.1. The source maps config is the template's, and the middleware is a copy of the template's with fix 1. 8/8 pass:

    • in Miniflare:
      • a throwing server function returns the same 200 body as without capture, with fn: "sendInvoice";
      • a throwing server.handlers route returns a 500 Internal Server Error with the header, and the same 500 without it when capture is off;
      • a route's own 502 in the SSO shape is reported as response with its details;
      • TanStack's page-route 500 to Accept: application/json is untouched;
    • in plain workerd, with modules named as base44 site deploy uploads them: the frames name the uploaded modules (assets/router-….js, assets/server-fns-….js, index.js), and each <file>.map exists in the build.

What workerd prints: a deployed Worker's frames carry the module name exactly as uploaded, e.g. at chargeCard (assets/router-BL_ldMyJ.js:1177:14), so they need no normalizing. Under Miniflare they come out as file:///<absolute path>/dist/server/assets/…, because Miniflare appends //# sourceURL=file://… to every module. That's a local-dev artifact, and why the frame-path check runs in plain workerd.

The release must contain

  • These four exports and RecordServerErrorOptions, in the next @base44/sdk release (0.8.54 if nothing else ships first; npm is at 0.8.53).
  • Apper should require that version (^0.8.54) in the template's package.json and lockfile, in template_packages.py, and as sdk_min_version for the update-base44-packages codemod. It also needs fix 1 in the template middleware and in #30614's codemod.

🤖 Generated with Claude Code

egidijusj and others added 2 commits October 13, 2026 10:14
Add recordServerError, annotateServerResponse, serverErrorResponse and
getAnonymousVisitorId. A full-stack (TanStack Start) app's Worker uses them,
through the app template's middleware, to report its server errors to Base44
in the X-B44-Function-Error response header (v2), only when serving asks with
X-B44-Capture-Errors: 1. No TanStack dependency and no new runtime dependency.

Tests: Vitest units, a contract fixture checked against apper's
read_function_error(version=2), and an end-to-end TanStack Start app built
with @cloudflare/vite-plugin and run in Miniflare and workerd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'t fit

- The root .gitignore's lib/ rule had dropped the e2e app's
  src/lib/error-capture-middleware.js, so the app couldn't build.
- Escaping can grow a 500-byte message to 3,000 bytes (\u0001 per control
  character), so with name and fn the header could miss 3,072 bytes even
  without frames, and no report was written. Cut the message to the most
  code points that fit instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant