next
Version:
The React Framework
341 lines (340 loc) • 16.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
Object.defineProperty(exports, "getCachedImageResponseBody", {
enumerable: true,
get: function() {
return getCachedImageResponseBody;
}
});
const _nodestream = require("node:stream");
const _nodecrypto = require("node:crypto");
const _invarianterror = require("../../shared/lib/invariant-error");
const _workasyncstorageexternal = require("../app-render/work-async-storage.external");
const _workunitasyncstorageexternal = require("../app-render/work-unit-async-storage.external");
const _dynamicrendering = require("../app-render/dynamic-rendering");
const _dynamicrenderingutils = require("../dynamic-rendering-utils");
const _manifestssingleton = require("../app-render/manifests-singleton");
const _static = require("react-server-dom-webpack/static");
const _client = require("react-server-dom-webpack/client");
function importOgModule() {
// Cache Components is Node-only (rejected for the edge runtime at compile
// time), so we always load the Node build. Loading it dynamically keeps the
// heavy `@vercel/og` renderer (satori + WASM) off the module-load path, so
// it's pulled in only when an image is actually rendered.
return import('next/dist/compiled/@vercel/og/index.node.js');
}
function getCachedImageResponseBody(args) {
return new ReadableStream({
async start (controller) {
const arrayBuffer = await getCachedImageResponseArrayBuffer(args);
if (arrayBuffer.byteLength > 0) {
controller.enqueue(new Uint8Array(arrayBuffer));
}
controller.close();
}
});
}
async function getCachedImageResponseArrayBuffer(args) {
const workUnitStore = _workunitasyncstorageexternal.workUnitAsyncStorage.getStore();
switch(workUnitStore == null ? void 0 : workUnitStore.type){
case 'prerender':
break;
case undefined:
case 'request':
case 'cache':
case 'private-cache':
case 'unstable-cache':
case 'prerender-runtime':
case 'prerender-client':
case 'validation-client':
case 'prerender-ppr':
case 'prerender-legacy':
case 'generate-static-params':
return renderImageResponseArrayBuffer(args);
default:
return workUnitStore;
}
const { cacheSignal, resumeDataCache, renderSignal } = workUnitStore;
if (!resumeDataCache) {
return renderImageResponseArrayBuffer(args);
}
const workStore = _workasyncstorageexternal.workAsyncStorage.getStore();
if (!workStore) {
throw Object.defineProperty(new _invarianterror.InvariantError('Expected a work store while caching an `ImageResponse` during prerendering.'), "__NEXT_ERROR_CODE", {
value: "E1382",
enumerable: false,
configurable: true
});
}
const [element, options] = args;
// `createHangingInputAbortSignal` aborts once the prerender's cache-sourced
// input is ready, so anything the serialization below is still awaiting past
// that point can be treated as dynamic (non-cache) input. In the prospective
// pass it aborts when `cacheSignal.inputReady()` resolves (no cache reads
// in progress); in the final pass the caches are already filled, so it just
// aborts on the next tick.
const hangingInputAbortSignal = (0, _dynamicrendering.createHangingInputAbortSignal)(workUnitStore);
// We open the cache read lazily, once we know the serialization completed and
// we're about to render and store the image. Opening it before serializing
// would keep `cacheSignal.inputReady()` from resolving and thus prevent the
// abort signal from ever firing, deadlocking the prospective prerender.
let readState = 'ready';
function beginReadOnce() {
if (readState === 'ready') {
readState = 'pending';
cacheSignal == null ? void 0 : cacheSignal.beginRead();
}
}
function endReadIfStarted() {
if (readState === 'pending') {
cacheSignal == null ? void 0 : cacheSignal.endRead();
}
readState = 'done';
}
// We serialize the element tree with `prerenderToNodeStream` rather than
// `renderToPipeableStream`. It's the right fit for prerendering, and it
// schedules work deferred for size (`deferTask`) on microtasks, so a fully
// static tree finishes flushing before the abort signal fires; a tree still
// pending at abort time is then genuinely waiting on dynamic input rather
// than just deferred.
//
// `renderToPipeableStream` would schedule that deferred work on
// `setImmediate` instead, which isn't necessarily a deal-breaker: the
// sequential-task scheme page rendering uses (`runInSequentialTasks`) drains
// pending immediates at each task boundary, so deferred work still runs in
// time. But route handler prerendering doesn't use that scheme, so here the
// deferred immediates would race the abort.
//
// The prerender halts silently on abort, leaving unfulfilled references in
// place rather than reporting through `onError`. So to tell a halt (the tree
// needed dynamic input) apart from a normal completion, we record whether the
// abort fired before the serialization finished. `abort()` runs this listener
// synchronously, well before we read `resultIsPartial` below.
let prerenderCompleted = false;
let resultIsPartial = false;
let serializationError;
hangingInputAbortSignal.addEventListener('abort', ()=>{
if (!prerenderCompleted) {
resultIsPartial = true;
}
}, {
once: true
});
const { clientModules, rscModuleMapping } = (0, _manifestssingleton.getClientReferenceManifest)();
try {
// We serialize only the `element`. It's the part that needs Flight, to run
// its async Server Components once and to surface any dynamic input. The
// `options` are already-resolved plain data; they're folded into the cache
// key directly and passed to satori as-is below.
const { prelude } = await (0, _static.prerenderToNodeStream)(element, clientModules, {
signal: hangingInputAbortSignal,
filterStackFrame: undefined,
onError (error) {
// A halt (our deliberate abort) emits nothing, so this is only called
// for genuine serialization errors. We surface the first one.
if (serializationError === undefined && !resultIsPartial) {
serializationError = error;
}
}
});
prerenderCompleted = true;
if (serializationError !== undefined) {
throw serializationError;
}
if (resultIsPartial) {
// The element tree needed dynamic input (e.g. `cookies()` or an uncached
// `fetch`), so the image can't be produced statically. Return a hanging
// promise: the body never resolves, and the final prerender's macrotask
// budget then classifies the route as dynamic.
// Whatever dynamic input made the element partial already classified
// itself when it created its own hanging promise (cookies() creates a
// runtime hanging promise, an uncached fetch creates a dynamic one,
// ...), so this wrapper adds no new information and can use the
// non-recording dynamic variant.
return (0, _dynamicrenderingutils.makeDynamicHangingPromise)(renderSignal, workStore.route, 'dynamic `ImageResponse`');
}
// The serialization finished before any dynamic input was needed, so we
// will render and cache the image. Hold the cache read now, before the
// stream is buffered and deserialized below, so that the prospective
// prerender's `cacheReady()` waits for the image to be stored.
beginReadOnce();
const chunks = [];
for await (const chunk of prelude){
chunks.push(chunk);
}
const elementBuffer = Buffer.concat(chunks);
// Derive a stable cache key from the serialized element plus the options.
// We hash rather than reuse the raw serialized bytes so the key stays
// compact even for large inputs (e.g. embedded fonts), and we fold the
// options in by content so two images that differ only in their options
// (size, fonts, ...) don't collide. The options are hashed directly here,
// never serialized through Flight, which would both bloat the key and apply
// `Buffer.prototype .toJSON` to font data.
const hash = (0, _nodecrypto.createHash)('sha256');
hash.update(elementBuffer);
updateHashWithOptions(hash, options);
const cacheKey = hash.digest('base64');
const cached = resumeDataCache.imageResponses.get(cacheKey);
if (cached) {
return await cached;
}
// Deserialize the element and hand it to satori. Because the user's
// components already ran during serialization, satori only walks resolved
// host elements and never re-runs them, confining user-space I/O to the
// in-store serialization above.
const deserializedElement = await (0, _client.createFromNodeStream)(_nodestream.Readable.from([
elementBuffer
]), {
// We don't want to trigger preloads of client references here.
moduleLoading: null,
moduleMap: rscModuleMapping,
serverModuleMap: (0, _manifestssingleton.getServerModuleMap)()
}, {
findSourceMapURL: undefined
});
// The Flight client hands back the output of an async Server Component as
// a `React.lazy` (sync components and plain host elements are inlined).
// satori can't unwrap lazies, so we resolve them into plain elements first.
// We only reach here once the serialization completed, so every lazy is
// already resolved and `_init` returns synchronously.
const resolvedElement = resolveFlightLazies(deserializedElement);
// Pair the resolved element with the original, in-memory `options`, which
// never went through Flight. This keeps the font `Buffer` intact: had it
// been serialized, Flight would apply the `toJSON` method that Node's
// `Buffer` carries, turning it into a `{ type: 'Buffer', data: [...] }`
// object that satori's font parser rejects (it needs an `ArrayBuffer` or a
// typed array).
const resolvedArgs = [
resolvedElement,
options
];
// Render satori outside the prerender work-unit store. It does uncached
// `fetch` calls (e.g. loading a font), and inside a Cache Components
// prerender an uncached `fetch` outside a cache scope becomes a hanging
// promise. Those are framework fetches, not user I/O, so we let them
// resolve normally with no store.
const arrayBufferPromise = _workunitasyncstorageexternal.workUnitAsyncStorage.exit(()=>renderImageResponseArrayBuffer(resolvedArgs));
if (resumeDataCache.mutable) {
resumeDataCache.imageResponses.set(cacheKey, arrayBufferPromise);
}
return await arrayBufferPromise;
} finally{
endReadIfStarted();
}
}
/**
* Updates a hash with a stable encoding of the `ImageResponse` options so they
* can participate in the cache key without being serialized through Flight.
* Binary values (font `Buffer`s, `ArrayBuffer`s, typed arrays) are hashed by
* their raw bytes; objects are walked in sorted-key order.
*
* `ImageResponse` options are plain data: numbers, strings, booleans, nested
* plain objects/arrays, and binary font data. Exotic objects such as `Map` or
* `Date` keep their state outside their enumerable own keys, so the key walk
* below would hash them incorrectly. Options never contain these, but we warn
* if one ever shows up so a mis-keyed cache can be reported.
*
* The encoding is self-delimiting: every node starts with a type tag, and
* variable-length parts (byte runs, primitives, keys) are length-prefixed,
* while arrays and objects are count-prefixed. This makes it injective, so no
* concatenation of values can be mistaken for a differently shaped input.
*/ function updateHashWithOptions(hash, value) {
if (value === undefined) {
hash.update('u');
return;
}
if (value === null) {
hash.update('n');
return;
}
const type = typeof value;
if (type !== 'object') {
// Tag with the primitive type so e.g. the number `1` and the string `'1'`
// don't hash the same.
updateHashWithBytes(hash, 'p', Buffer.from(`${type}:${String(value)}`));
return;
}
if (value instanceof ArrayBuffer) {
updateHashWithBytes(hash, 'a', new Uint8Array(value));
return;
}
if (ArrayBuffer.isView(value)) {
updateHashWithBytes(hash, 'v', new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
return;
}
if (Array.isArray(value)) {
hash.update(`[${value.length},`);
for (const item of value){
updateHashWithOptions(hash, item);
}
return;
}
// The key walk below captures a plain object faithfully, but an exotic object
// keeps its state elsewhere (a `Map`'s/`Set`'s entries, a `Date`'s time), so
// two different values would hash the same and could return the wrong cached
// image. This shouldn't happen for `ImageResponse` options, so we warn rather
// than fail, then hash best-effort, so it can be reported. Not gated on
// `NODE_ENV`: this runs during the production `next build` prerender, where
// the warning is most useful.
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
var _value_constructor;
const typeName = ((_value_constructor = value.constructor) == null ? void 0 : _value_constructor.name) ?? 'object';
console.warn(`Cannot reliably include an \`ImageResponse\` option of type ` + `\`${typeName}\` in the cache key, so different images may collide and ` + `return an incorrect cached result. Please report this to the Next.js ` + `team.`);
}
const keys = Object.keys(value).sort();
hash.update(`{${keys.length},`);
for (const key of keys){
updateHashWithBytes(hash, 'k', Buffer.from(key));
updateHashWithOptions(hash, value[key]);
}
}
/**
* Hashes a length-prefixed, tagged byte run: `<tag><byteLength>:<bytes>`. The
* length prefix keeps the run self-delimiting so it can't blend into adjacent
* nodes.
*/ function updateHashWithBytes(hash, tag, bytes) {
hash.update(`${tag}${bytes.byteLength}:`);
hash.update(bytes);
}
async function renderImageResponseArrayBuffer(args) {
const OGImageResponse = (await importOgModule()).ImageResponse;
const imageResponse = new OGImageResponse(...args);
if (!imageResponse.body) {
return new ArrayBuffer(0);
}
return imageResponse.arrayBuffer();
}
const REACT_LAZY_TYPE = Symbol.for('react.lazy');
/**
* Recursively replaces the `React.lazy` references that Flight emits for
* resolved async Server Components with the elements they resolve to, so that
* satori (which doesn't understand lazy nodes) can walk the tree. This must
* only be called on a fully resolved (completed) Flight result, where each
* lazy's `_init` returns synchronously rather than suspending.
*/ function resolveFlightLazies(node) {
if (node === null || typeof node !== 'object') {
return node;
}
if (node.$$typeof === REACT_LAZY_TYPE) {
const lazy = node;
return resolveFlightLazies(lazy._init(lazy._payload));
}
if (Array.isArray(node)) {
return node.map(resolveFlightLazies);
}
const element = node;
if (element.props && 'children' in element.props) {
return {
...element,
props: {
...element.props,
children: resolveFlightLazies(element.props.children)
}
};
}
return node;
}
//# sourceMappingURL=cache-image-response.js.map