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
2 changes: 1 addition & 1 deletion sst/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 15 additions & 1 deletion sst/src/next/cache-handler.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions sst/src/next/cache-handler.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<html></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: '<html></html>', rscData: { __b64: RSC.toString('base64') }, segmentData: {} }, lastModified: 1 }),
);
assert.equal(await new S3CacheHandler({}).get('/privacy-policy', {}), null);
});
Loading