simocracy-v2 · create-sim-node24-regression

Create Sim blocked by Node 24 undici regression

@daviddao

Every write through /api/records blew up with expected non-null body source and the UI surfaced that exact message when saving a sim. Root cause: Vercel flipped its default runtime to Node 24, whose bundled undici 7.x asserts on a code path Next.js 16's fetch patching walks on every write. Fixed by pinning engines.node to 22.x.

Date: 2026-04-22 · reproduced · merged to main · verified live on prod


Headline result

500
Before: /api/upload-blob + /api/records POST
200
After: same flow clean through
4
Lines changed (package.json + .nvmrc)
Sim written to PDS post-fix

Reproduced on prod, patched, and verified. Signed in as satyam2.climateai.org, opened Create New Sim, clicked Save to ATProto → the dialog painted expected non-null body source and no record landed on the PDS. Root-caused to Node 24's undici 7.x interacting with Next.js 16's fetch patching on every @atproto/api write. Pinned engines.node to 22.x (commit 717090a), merged to main, Vercel redeployed on Node 22. Re-ran the same flow — sim Node22FixVerified lands on the PDS at 2026-04-22T19:50:50Z, carousel now shows 4 sims. Green across the board.

Walkthrough

01Signed in as satyam2.climateai.org. Carousel shows the three existing sims — Nyariko, Nyaori Katalog, FixTestSim. Clicked + Create New Sim.
02AppearanceEditorDialog rendered cleanly. Name field accepted NullRefTest; sprite preview animated normally; Save to ATProto became enabled.
03Click → spinner appears. The client kicks off generateAvatarThumbnailPOST /api/upload-blobPOST /api/records (org.simocracy.sim).
04Spinner dies. The avatar-save.tsx catch block paints the server-returned JSON error field into the dialog: expected non-null body source. No toast, no retry. Nothing written to the PDS.
06After merging 717090a to main and letting Vercel redeploy: re-ran the identical flow, carousel now shows 4 sims, new record Node22FixVerified is first in com.atproto.repo.listRecords on climateai.org.

Findings

Blocker — every ATProto write 500s with expected non-null body source

Reproduced directly against production for the Create Sim flow, and already independently reported by a sibling run (template-event-image-url) for org.simocracy.gathering and org.simocracy.interviewTemplate. Same error body, different collection — this is not per-lexicon, it's every POST that goes through @atproto/api.

Reads (com.atproto.repo.listRecords, getRecord) succeed — so session, DPoP, and OAuth state are all fine. The failure is on the way out of our server, not on the PDS side.

Root cause. Three-way interaction between:

  1. Vercel's default Node runtime flipped to 24.x (per the Vercel Sandbox changelog, Jan 2026). Our package.json had no engines field, so a redeploy silently started landing on Node 24.
  2. Node 24 ships undici 7.x, which added a strict assertion: assert(request.body.source != null) (nodejs/undici#5004).
  3. Next.js 16's fetch patching re-creates every outgoing Request by reading .body as a ReadableStream, which sets body.source = null. The ATProto agent's first write request (POST with a JSON body) goes through this patched fetch → undici asserts → the error bubbles back as XRPCError/api/records catches it and returns it to the client verbatim.

All three have to line up, which is why reads, non-fetch paths, and local dev on Node 22 still work.

Tracking issues:

Fix landed and verified — pinned Vercel to Node 22 via engines.node

Branch fixing/bug, commit 717090a. Two-file change:

// package.json
{
  "name": "simocracy-v2",
  "version": "0.1.0",
  "private": true,
+ "engines": {
+   "node": "22.x"
+ },
  "scripts": { ... }
}

// .nvmrc (new file)
22

Vercel honours the engines field at build/runtime selection. On Node 22 the bundled undici is 6.x, which does not have this assertion, so the whole ATProto write path clears. Merged to main and verified live — the Create New Sim flow that was surfacing the error five minutes earlier now writes Node22FixVerified to the PDS (see screenshot 06 + post-fix XRPC check below).

This is deliberately the smallest possible patch — upstream fixes (undici#4941 / #5006, plus whatever atproto ships for #4840) will eventually reach Node 24, at which point we can revisit the pin.

Warning — error copy leaks a server-internal string to the user

components/avatar/avatar-save.tsx surfaces the server's raw error field directly (throw new Error(data.error || …)) which is how expected non-null body source ended up in the dialog. That's accidentally useful for debugging (it's how I zeroed in on the cause in one screenshot) but it's a terrible user-facing message.

Low priority and not worth delaying the Node-22 pin, but worth a follow-up: catch 5xx in the same place, show a friendly “Couldn't save your sim — try again”, and keep the verbose string in console.error.

What worked — everything downstream of the ATProto agent

Reads against com.atproto.repo.listRecords on climateai.org returned the three existing sims correctly (see XRPC evidence below). The client half of the create flow — avatar rendering, thumbnail generation, form validation, name input, PNG serialisation — all behaved. The failure is isolated to the POST body that @atproto/api emits server-side, which means the fix is narrow.

XRPC evidence

Before the fix — resolve handle, follow DID doc to PDS, list org.simocracy.sim. If the create had succeeded, NullRefTest would be at the top with today's createdAt. It isn't — the latest sim on this repo is from 2026-04-20, two days stale.

$ curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=satyam2.climateai.org"
{"did":"did:plc:cpoagodpqrgs4t7thi5z37uf"}

$ curl -s "https://plc.directory/did:plc:cpoagodpqrgs4t7thi5z37uf" | jq '.service[0].serviceEndpoint'
"https://climateai.org"

$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords\
?repo=did:plc:cpoagodpqrgs4t7thi5z37uf\
&collection=org.simocracy.sim&limit=25" \
   | jq -r '.records[] | "\(.value.createdAt)  \(.value.name)"'

2026-04-20T03:55:14.866Z  FixTestSim
2026-04-19T23:09:47.677Z  Nyaori Katalog
2026-04-19T23:07:51.764Z  Nyariko

After the fix

Post-deploy on 717090a (Node 22 runtime). Same command, same repo — new record at the top with today's timestamp, proving /api/upload-blob and /api/records POST both completed and the PDS accepted the write.

$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords\
?repo=did:plc:cpoagodpqrgs4t7thi5z37uf\
&collection=org.simocracy.sim&limit=25" \
   | jq -r '.records[] | "\(.value.createdAt)  \(.value.name)"'

2026-04-22T19:50:50.530Z  Node22FixVerified   ← written under the new Node 22 runtime
2026-04-20T03:55:14.866Z  FixTestSim
2026-04-19T23:09:47.677Z  Nyaori Katalog
2026-04-19T23:07:51.764Z  Nyariko

Reproducing

Targethttps://www.simocracy.org Test accountsatyam2.climateai.org · did:plc:cpoagodpqrgs4t7thi5z37uf · PDS climateai.org Date2026-04-22 Steps1. /my-simsSign in → enter handle → OAuth on climateai.org.
2. Click + Create New Sim.
3. Type any name → Save to ATProto.
4. Observe the spinner flip to expected non-null body source inline in the dialog.
5. Confirm no new record at com.atproto.repo.listRecords(collection=org.simocracy.sim) on the user's PDS.
Screenshotsassets/01…04-*.png XRPC evidenceassets/05-pds-shows-no-new-record.txt Patchbranch fixing/bug · commit 717090a · +engines.node: "22.x" + .nvmrc