Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions sst/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
16 changes: 14 additions & 2 deletions sst/src/next/cache-handler.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
55 changes: 55 additions & 0 deletions sst/src/next/cache-handler.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading