From c64c04a9cb2bccea3087fbe80ba5ce45c7562cfe Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 24 Oct 2026 11:36:20 -0400 Subject: [PATCH] SMOODEV-4146: Keep APP_PAGE segmentData a Map in the S3 cache handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next 16 stores an APP_PAGE's per-segment prefetch payloads in a Map, and JSON.stringify has no form for a Map — it was stored as {}. On a cache hit Next calls cachedData.segmentData.get(...), which threw, so every client segment prefetch (Next-Router-Segment-Prefetch) of an ISR/SSG page answered 500: smoo.ai's /, /help, /data-deletion and /privacy-policy, prefetched from links all over the dashboard. Maps now travel as { __map: entries } (their Buffers encoded as before), and an entry written before this fix — segment payloads already lost — reads as a miss so Next regenerates it instead of serving it. Release 0.2.3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LbevoiGNpgJy6qTPvk4EVt --- sst/package.json | 2 +- sst/src/next/cache-handler.cjs | 16 +++++++++++++++- sst/src/next/cache-handler.test.cjs | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/sst/package.json b/sst/package.json index 98955e2..1c58720 100644 --- a/sst/package.json +++ b/sst/package.json @@ -1,6 +1,6 @@ { "name": "@smooai/deploy", - "version": "0.2.2", + "version": "0.2.3", "description": "Shared SmooAI deploy primitives \u2014 reusable SST v4 constructs (API Gateway WebSocket + Rust Lambda + DynamoDB single-table + S3 blob bucket + S3 Vectors placeholder). Consumed by smooth-operator and dogfooded by smooai.", "license": "MIT", "type": "module", diff --git a/sst/src/next/cache-handler.cjs b/sst/src/next/cache-handler.cjs index 90a5ea2..de7116f 100644 --- a/sst/src/next/cache-handler.cjs +++ b/sst/src/next/cache-handler.cjs @@ -160,16 +160,23 @@ function isFetch(ctx) { // Buffer-aware (de)serialization: preserve Buffers faithfully across the wire. // JSON.stringify calls Buffer#toJSON *before* the replacer, so `v` is already // `{ type: 'Buffer', data }` — read the raw value off the holder (`this[k]`). +// Maps (an APP_PAGE's `segmentData`) have no JSON form at all and stringified to +// `{}` (SMOODEV-4146), so they travel as `{ __map: [[key, value], …] }`; the +// replacer then recurses into the entries, so their Buffers are encoded too. function serialize(entry) { return JSON.stringify(entry, function (k, v) { const raw = this[k]; - return Buffer.isBuffer(raw) ? { __b64: raw.toString('base64') } : v; + if (Buffer.isBuffer(raw)) return { __b64: raw.toString('base64') }; + if (raw instanceof Map) return { __map: [...raw.entries()] }; + return v; }); } function deserialize(str) { return JSON.parse(str, (_k, v) => { if (!v || typeof v !== 'object') return v; if (typeof v.__b64 === 'string') return Buffer.from(v.__b64, 'base64'); + // The reviver runs bottom-up, so the entries' Buffers are already decoded. + if (Array.isArray(v.__map)) return new Map(v.__map); // Entries written before the fix above carry Buffer#toJSON's shape; Next // would write that object to the response as "[object Object]". if (v.type === 'Buffer' && Array.isArray(v.data)) return Buffer.from(v.data); @@ -196,6 +203,13 @@ module.exports = class S3CacheHandler { try { const res = await s.s3.send(new s.GetObjectCommand({ Bucket: s.bucket, Key: s3Key(s.prefix, key, fetchKind) })); const entry = deserialize(await streamToString(res.Body)); + // Written before the Map fix: its segment payloads are gone (`{}`), and + // Next's `segmentData.get(...)` would throw on it. Regenerate instead. + const segmentData = entry.value && entry.value.segmentData; + if (segmentData && !(segmentData instanceof Map)) { + emit({ type: 'miss', op: 'get', key, fetch: fetchKind }); + return null; + } emit({ type: 'hit', op: 'get', key, fetch: fetchKind }); return { lastModified: entry.lastModified, value: entry.value }; } catch (e) { diff --git a/sst/src/next/cache-handler.test.cjs b/sst/src/next/cache-handler.test.cjs index 1087868..d58846e 100644 --- a/sst/src/next/cache-handler.test.cjs +++ b/sst/src/next/cache-handler.test.cjs @@ -53,3 +53,32 @@ test('an entry written before the fix ({ type: "Buffer", data }) still decodes t assert.ok(Buffer.isBuffer(hit.value.body)); assert.deepEqual(hit.value.body, PNG); }); + +// SMOODEV-4146: Next 16 keeps an APP_PAGE's per-segment prefetch payloads in a +// Map. JSON.stringify turned it into {}, so on a hit Next's +// `cachedData.segmentData.get(...)` threw and every segment prefetch of a cached +// page (Next-Router-Segment-Prefetch: /_tree) answered 500. +const RSC = Buffer.from('0:{"tree":1}'); +const SEGMENTS = new Map([ + ['/_tree', Buffer.from('tree-payload')], + ['/_index', Buffer.from('index-payload')], +]); + +test('an APP_PAGE segmentData Map round-trips as a Map of Buffers', async () => { + const handler = new S3CacheHandler({}); + await handler.set('/help', { kind: 'APP_PAGE', html: '', rscData: RSC, segmentData: SEGMENTS, status: 200, headers: {} }, {}); + const hit = await handler.get('/help', {}); + assert.ok(hit.value.segmentData instanceof Map, 'segmentData should be a Map'); + assert.deepEqual([...hit.value.segmentData.keys()], ['/_tree', '/_index']); + assert.ok(Buffer.isBuffer(hit.value.segmentData.get('/_tree'))); + assert.deepEqual(hit.value.segmentData.get('/_tree'), SEGMENTS.get('/_tree')); + assert.deepEqual(hit.value.rscData, RSC); +}); + +test('an APP_PAGE written before the fix (segmentData lost as {}) is a miss, so Next regenerates it', async () => { + objects.set( + 'next-cache/test-build/privacy-policy.cache', + JSON.stringify({ value: { kind: 'APP_PAGE', html: '', rscData: { __b64: RSC.toString('base64') }, segmentData: {} }, lastModified: 1 }), + ); + assert.equal(await new S3CacheHandler({}).get('/privacy-policy', {}), null); +});