/**
* @lazarv/rsc — Reply Decoder
*
* Stateful, chunk-oriented decoder for client → server RSC replies.
*
* Wire-format compatibility:
* 1. Full backward-compat with the existing @lazarv/rsc tag set:
* $undefined $NaN $Infinity $-Infinity
* $$<rest> — escaped literal
* $S<symbolName> — Symbol.for(name)
* $n<digits> — BigInt
* $D<iso> — Date
* $Q<inline-json> — Map with entries inline
* $W<inline-json> — Set with items inline
* $l<url> — URL
* $U<inline-json> — URLSearchParams entries
* $K<partIdOrPath> — FormData / File / Blob lookup
* $AB<base64> — ArrayBuffer
* $AT<inline-json> — TypedArray / DataView
* $R<inline-json> — RegExp
* $h<hexPartId> — Server reference (outlined part)
* $T — Temporary reference (path-keyed)
* 2. NEW capabilities (additive, non-colliding tag letters):
* $<hex>[:key:key] — Row reference + path walk
* $@<hex> — Promise (outlined)
* $r<hex> — ReadableStream (text)
* $b<hex> — ReadableStream (binary)
* $x<hex> — AsyncIterable
* $X<hex> — Iterator (sync)
*
* Security model (matches React's post-CVE-2025-55182 barriers, plus extras):
*
* 1. Path walking in `$<id>:<key>:<key>` references requires:
* - Each intermediate value's prototype MUST be Object.prototype or
* Array.prototype. Anything else throws "Invalid reference.".
* - Each property step MUST be an own property (Object.hasOwn). This
* blocks `.constructor`, `.map`, `.then`, `.__proto__`, `.prototype`.
* 2. Forbidden keys (`__proto__`, `constructor`, `prototype`) are stripped
* via the JSON.parse reviver BEFORE they can become own properties,
* and never survive the path-walk check even if they do slip in.
* 3. Any `then` key whose value is a function is scrubbed to null at walk
* time (attacker thenables cannot be duck-typed by downstream Promise
* code). Non-function `then` values are preserved.
* 4. Callables originate ONLY from:
* - `$h<id>` → moduleLoader.loadServerAction(id) (allowlist-bound)
* - `$T` → temporaryReferences proxy (opaque, throws on access)
* No path invokes `new Function`, `eval`, or `import()` on user data.
* 5. Resource ceilings: maxRows, maxDepth, maxBytes, maxStringLength,
* maxBigIntDigits, maxBoundArgs, maxStreamChunks.
*
* Architecture:
*
* Parsing happens in two passes per row to preserve *path identity* for
* temporary references (which are keyed by the structural path the
* client assigned on the encode side):
* 1. JSON.parse with a reviver that only strips __proto__ / constructor
* / prototype keys. This produces a plain tree with no tag dispatch.
* 2. A recursive walk that tracks the current path and dispatches
* $-prefixed strings inline, recursing into objects/arrays, and
* outlining row references through the chunk map.
*/
// ─── Chunk status constants ────────────────────────────────────────────────
const BLOCKED = "blocked";
const RESOLVED_MODEL = "resolved_model";
const FULFILLED = "fulfilled";
const REJECTED = "rejected";
// ─── Resource limits ───────────────────────────────────────────────────────
export const DEFAULT_LIMITS = Object.freeze({
maxRows: 10_000,
maxDepth: 128,
maxBytes: 32 * 1024 * 1024,
maxBoundArgs: 256, // matches React
maxBigIntDigits: 4096, // matches React
maxStringLength: 16 * 1024 * 1024,
maxStreamChunks: 10_000,
});
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
// ─── Errors ────────────────────────────────────────────────────────────────
export class DecodeError extends Error {
constructor(message, code) {
super(message);
this.name = "DecodeError";
this.code = code ?? "DECODE_ERROR";
}
}
export class DecodeLimitError extends DecodeError {
constructor(limit, observed) {
super(`Reply exceeded decode limit: ${limit} (observed ${observed})`);
this.name = "DecodeLimitError";
this.code = "DECODE_LIMIT";
this.limit = limit;
this.observed = observed;
}
}
// ─── Temporary reference proxy ─────────────────────────────────────────────
const TEMPORARY_REFERENCE_TAG = Symbol.for("react.temporary.reference");
const temporaryReferenceProxyHandler = {
get(target, prop) {
if (prop === "$$typeof") return target.$$typeof;
if (prop === Symbol.toPrimitive) return undefined;
if (prop === "then") return undefined;
throw new Error(
"Attempted to read a property of a temporary Client Reference from the server. " +
"Temporary references are opaque and cannot be inspected."
);
},
set() {
throw new Error(
"Cannot assign to a temporary client reference from a server module."
);
},
};
function createTemporaryReference(temporaryReferences, id) {
const reference = Object.defineProperties(
function () {
throw new Error(
"Attempted to call a temporary Client Reference from the server but it is on the client. " +
"It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component."
);
},
{ $$typeof: { value: TEMPORARY_REFERENCE_TAG } }
);
const proxy = new Proxy(reference, temporaryReferenceProxyHandler);
if (temporaryReferences && typeof temporaryReferences.set === "function") {
temporaryReferences.set(proxy, id);
}
return proxy;
}
// ─── ReplyResponse ─────────────────────────────────────────────────────────
//
// The decoding state carried through a single decodeReply call. Kept as a
// plain object built by a factory rather than a class because it has no
// methods — all operations on it are module-level helpers that take the
// response as their first argument. This keeps the type monomorphic for V8
// and avoids a near-empty class shell.
function buildReplyResponse(prefix, formData, options) {
const response = {
_prefix: prefix,
_formData: formData,
_chunks: new Map(), // rowId → chunk
_temporaryReferences: options.temporaryReferences ?? null,
_moduleLoader: options.moduleLoader ?? null,
_limits: { ...DEFAULT_LIMITS, ...options.limits },
_depth: 0,
};
if (formData) {
let byteCount = 0;
let entryCount = 0;
for (const [, v] of formData.entries()) {
entryCount++;
if (entryCount > response._limits.maxRows) {
throw new DecodeLimitError("maxRows", entryCount);
}
if (typeof v === "string") byteCount += v.length;
else if (v && typeof v.size === "number") byteCount += v.size;
if (byteCount > response._limits.maxBytes) {
throw new DecodeLimitError("maxBytes", byteCount);
}
}
}
return response;
}
export function createReplyResponse(prefix, formData, options = {}) {
return buildReplyResponse(prefix ?? "", formData ?? null, options);
}
// ─── Chunk accessors ───────────────────────────────────────────────────────
function getChunk(response, id) {
const cached = response._chunks.get(id);
if (cached) return cached;
if (!response._formData) {
const c = {
status: REJECTED,
value: null,
reason: new DecodeError(`Row ${id} missing: no FormData body`),
};
response._chunks.set(id, c);
return c;
}
// Row keys are stored as decimal strings by the encoder, per the existing