simocracy-v2 · create-sim-node24-regression
undici regressionEvery 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.
package.json + .nvmrc)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.
satyam2.climateai.org. Carousel shows the three existing sims — Nyariko, Nyaori Katalog, FixTestSim. Clicked + Create New Sim.
AppearanceEditorDialog rendered cleanly. Name field accepted NullRefTest; sprite preview animated normally; Save to ATProto became enabled.
generateAvatarThumbnail → POST /api/upload-blob → POST /api/records (org.simocracy.sim).
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.
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.expected non-null body sourceReproduced 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:
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.undici 7.x, which added a strict assertion: assert(request.body.source != null) (nodejs/undici#5004).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:
engines.nodeBranch 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.
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.
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.
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
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
satyam2.climateai.org · did:plc:cpoagodpqrgs4t7thi5z37uf · PDS climateai.org
Date2026-04-22
Steps1. /my-sims → Sign in → enter handle → OAuth on climateai.org.expected non-null body source inline in the dialog.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