From f2fc2f0e0406b51db3ae3e99670d57ac04e04463 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 10 Oct 2026 17:16:18 -0400 Subject: [PATCH] SMOODEV-3805: Keep Buffers intact in the S3 Next cache handler JSON.stringify calls Buffer#toJSON before the replacer, so the replacer never saw a Buffer and APP_ROUTE bodies were stored as { type: 'Buffer', data }. On a cache hit Next wrote that object as the 15-byte text "[object Object]", which broke smoo.ai's favicon.ico, opengraph-image and twitter-image. serialize now reads the raw value off the holder, and deserialize also decodes the old shape so entries already in S3 serve correctly without a purge. Release 0.2.2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UwsWFhUD6BK9mXkakhUUJ6 --- .github/workflows/pr-checks.yml | 3 ++ sst/package.json | 5 +-- sst/src/next/cache-handler.cjs | 16 +++++++-- sst/src/next/cache-handler.test.cjs | 55 +++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 sst/src/next/cache-handler.test.cjs diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 16cd5b8..e4212ea 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -53,6 +53,9 @@ jobs: - name: Typecheck (tsc --noEmit) run: pnpm typecheck + - name: Cache handler tests (node --test) + run: pnpm test + # ── Helm chart: lint + render smoke + schema validate ──────────────────── helm-chart: name: Helm chart diff --git a/sst/package.json b/sst/package.json index d95a876..98955e2 100644 --- a/sst/package.json +++ b/sst/package.json @@ -1,6 +1,6 @@ { "name": "@smooai/deploy", - "version": "0.2.1", + "version": "0.2.2", "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", @@ -23,7 +23,8 @@ "src" ], "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "node --test src/next/cache-handler.test.cjs" }, "peerDependencies": { "@aws-sdk/client-s3": ">=3", diff --git a/sst/src/next/cache-handler.cjs b/sst/src/next/cache-handler.cjs index 2aba998..90a5ea2 100644 --- a/sst/src/next/cache-handler.cjs +++ b/sst/src/next/cache-handler.cjs @@ -158,11 +158,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]`). function serialize(entry) { - return JSON.stringify(entry, (_k, v) => (Buffer.isBuffer(v) ? { __b64: v.toString('base64') } : v)); + return JSON.stringify(entry, function (k, v) { + const raw = this[k]; + return Buffer.isBuffer(raw) ? { __b64: raw.toString('base64') } : v; + }); } function deserialize(str) { - return JSON.parse(str, (_k, v) => (v && typeof v === 'object' && typeof v.__b64 === 'string' ? Buffer.from(v.__b64, 'base64') : v)); + return JSON.parse(str, (_k, v) => { + if (!v || typeof v !== 'object') return v; + if (typeof v.__b64 === 'string') return Buffer.from(v.__b64, 'base64'); + // 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); + return v; + }); } async function streamToString(body) { diff --git a/sst/src/next/cache-handler.test.cjs b/sst/src/next/cache-handler.test.cjs new file mode 100644 index 0000000..1087868 --- /dev/null +++ b/sst/src/next/cache-handler.test.cjs @@ -0,0 +1,55 @@ +// SMOODEV-3805: cached route bodies (favicon.ico, opengraph-image) must come back +// as Buffers. They used to come back as `{ type: 'Buffer', data }`, which Next +// wrote to the response as the 15-byte text "[object Object]". +// +// node --test src/next/cache-handler.test.cjs +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Module = require('node:module'); + +// In-memory stand-in for @aws-sdk/client-s3. +const objects = new Map(); +class GetObjectCommand { + constructor(input) { + this.input = input; + } +} +class PutObjectCommand { + constructor(input) { + this.input = input; + } +} +class S3Client { + async send(cmd) { + if (cmd instanceof PutObjectCommand) return void objects.set(cmd.input.Key, cmd.input.Body); + if (!objects.has(cmd.input.Key)) throw Object.assign(new Error('NoSuchKey'), { name: 'NoSuchKey' }); + const body = objects.get(cmd.input.Key); + return { Body: { transformToString: async () => body } }; + } +} +const realLoad = Module._load; +Module._load = function (request, ...rest) { + if (request === '@aws-sdk/client-s3') return { S3Client, GetObjectCommand, PutObjectCommand }; + return realLoad.call(this, request, ...rest); +}; +process.env.CACHE_BUCKET_NAME = 'test-bucket'; +process.env.NEXT_BUILD_ID = 'test-build'; + +const S3CacheHandler = require('./cache-handler.cjs'); +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]); + +test('an APP_ROUTE body round-trips as the same Buffer', async () => { + const handler = new S3CacheHandler({}); + await handler.set('/favicon.ico', { kind: 'APP_ROUTE', status: 200, body: PNG, headers: { 'content-type': 'image/x-icon' } }, {}); + const hit = await handler.get('/favicon.ico', {}); + assert.ok(Buffer.isBuffer(hit.value.body), 'body should be a Buffer'); + assert.deepEqual(hit.value.body, PNG); + assert.equal(hit.value.headers['content-type'], 'image/x-icon'); +}); + +test('an entry written before the fix ({ type: "Buffer", data }) still decodes to a Buffer', async () => { + objects.set('next-cache/test-build/opengraph-image.cache', JSON.stringify({ value: { kind: 'APP_ROUTE', body: PNG }, lastModified: 1 })); + const hit = await new S3CacheHandler({}).get('/opengraph-image', {}); + assert.ok(Buffer.isBuffer(hit.value.body)); + assert.deepEqual(hit.value.body, PNG); +});