Our example currently doesn't demonstrate closure encryption capability:
|
: transformHoistInlineDirective(code, ast, { |
|
directive, |
|
rejectNonAsyncFunction: true, |
|
hoistRuntime: true, |
|
runtime, |
|
}) |
#1392 indicated we may require core transform change to support it, but apparently framework owned cache runtime wrapper can likely implement this. So the goal of the issue is to such example without changing core transform utils.
1. Demonstrate Protected Inline Captures Without Losing Cache Identity
Source example
An inline cached function can close over values that affect its result:
function createProductReader(accountId) {
return async function getProduct(id) {
'use cache'
return db.product(accountId, id)
}
}
Current demo: direct captures work
The current callable cache example does not encrypt captures. Its generated shape is conceptually:
async function $$implementation(accountId, productId) {
return db.product(accountId, productId)
}
export const $$reference = registerServerReference(
cacheWrapper($$implementation),
)
function createProductReader(accountId) {
return $$reference.bind(null, accountId)
}
const read = createProductReader('account-7')
await read('product-1')
// wrapper receives ['account-7', 'product-1']
// cache key is ['account-7', 'product-1']
This works because the hoisted capture parameter is an ordinary wrapper argument. The wrapper keys both accountId and productId, then invokes the implementation with the same values. The existing development and production E2E proves this direct-capture behavior through Client Component submission.
Adding encryption exposes the bug
To prevent a captured value from crossing the client boundary as trusted plaintext, a framework can use the hoister's existing encode and decode hooks. Adding them to the current wrapper arrangement produces this shape:
async function $$implementation(protectedCaptures, productId) {
const [accountId] = decrypt(protectedCaptures)
return db.product(accountId, productId)
}
export const $$reference = registerServerReference(
cacheWrapper($$implementation),
)
function createProductReader(accountId) {
return $$reference.bind(null, encrypt([accountId]))
}
const read = createProductReader('account-7')
await read('product-1')
// cacheWrapper receives [protectedCaptures, 'product-1']
// $$implementation decrypts only after cacheWrapper selects a key
The decode happens too late. cacheWrapper constructs its key before calling $$implementation, so it keys the encrypted value rather than the decoded accountId.
Plugin-rsc's AES-GCM encryption generates a fresh random IV for each encryption at packages/plugin-rsc/src/utils/encryption-utils.ts:88. Two requests can therefore render and bind the same logical reader independently but receive different raw cache keys:
request A: createProductReader("account-7") -> encrypt with IV-A -> ciphertext-A
request B: createProductReader("account-7") -> encrypt with IV-B -> ciphertext-B
request A: read("product-1") -> raw key [ciphertext-A, "product-1"]
request B: read("product-1") -> raw key [ciphertext-B, "product-1"] -> cache miss
The fresh IV is intentional security behavior. Decrypting first reveals that both requests have the same logical key ['account-7', 'product-1'], so the second request can reuse the first entry.
Desired framework wrapper behavior
Cache-key construction belongs to the framework-owned cache wrapper, not the generic transform. Given a transform-produced signal that the first argument is a protected capture slot, the wrapper can behave conceptually like this:
// Framework-owned runtime
function cacheWrapper(implementation, { hasBoundArgs }) {
return async function cached(...transportArgs) {
let cacheArgs = transportArgs
if (hasBoundArgs) {
const [protectedCaptures, ...invocationArgs] = transportArgs
const captures = await decrypt(protectedCaptures)
cacheArgs = [...captures, ...invocationArgs]
}
// The existing private implementation can still receive transportArgs and
// perform its own hoister-generated decode before executing the source body.
return cache(cacheArgs, () => implementation(...transportArgs))
}
}
The transform-generated module only wires the hoisted implementation and metadata into that framework wrapper:
async function $$implementation(protectedCaptures, productId) {
const [accountId] = await decrypt(protectedCaptures)
return db.product(accountId, productId)
}
export const $$reference = registerServerReference(
cacheWrapper($$implementation, { hasBoundArgs: true }),
)
function createProductReader(accountId) {
return $$reference.bind(null, encrypt([accountId]))
}
Now the protected value can cross the client boundary without exposing or trusting plaintext capture data, while both requests use the logical key ['account-7', 'product-1']. This illustration retains the existing implementation-level decode, so the framework also decodes once for cache keying. A tighter generated adapter could avoid duplicate decoding, but the required semantic boundary is the same: the framework wrapper must have decoded captures before it selects a cache entry.
Implementation alternatives
The remaining requirement is to improve the callable-cache demo so protected captures are decoded before cache-key construction. This does not necessarily require a new transform primitive. There are two viable implementation levels.
The transform can report that it generated a protected bound slot. PR #1258's hasBoundArgs is one candidate, while a generated adapter or more general bound-slot description could provide the same static signal:
cacheWrapper($$implementation, { hasBoundArgs: true })
Alternatively, the framework can make its encode result self-describing and let its decode hook and cache wrapper share that transport format:
// Framework-owned encode hook
encodeCacheCaptures(captures)
// -> { type: 'use-cache-captures', encrypted: encrypt(captures) }
// Framework-owned cache wrapper
if (isCacheCaptureEnvelope(await transportArgs[0])) {
const captures = await decodeCacheCaptures(transportArgs[0])
// key decoded captures together with invocation arguments
}
The framework controls this reserved envelope, so the wrapper does not need transform metadata to discover the protected slot. Static metadata avoids a wire-level discriminator, while the envelope approach can be implemented with the existing encode and decode hooks plus the publicly exposed encryption runtime. The demo should use the smaller maintainable option rather than adding metadata solely to reproduce Next.js's generated shape.
Next.js usage and rationale
Next.js binds one encrypted payload to the registered inline cache wrapper, then decrypts it before constructing cache arguments in use-cache-wrapper.ts:1980. Server graph fixture 58 demonstrates the generated registered wrapper and protected closure binding.
This ordering is required by transported inline cache semantics. Configuring the hoister's decode hook would decode only inside the private implementation, after the cache wrapper has already selected a key. The Vite-side wrapper, registration, and binding order are detailed in FINDINGS-CACHE-SERVER-REFERENCE-TRANSPORT.md:118.
Independent boundary
This work proves that a framework can opt into protected capture transport without changing logical cache identity. It may result in transform metadata or remain entirely in the framework's envelope, encode, decode, and wrapper integration. It does not choose cache handlers, lifetime, invalidation, or storage policy. It also does not require source-parameter admission, stable generated names, method syntax support, or a replacement module lowering strategy.
Verification
- Existing direct-capture behavior remains functional.
- Repeated calls with equal decoded protected captures and arguments hit the same entry.
- Different decoded captures miss even when invocation arguments are equal.
- The protected capture value is not present in plaintext Flight output.
- The behavior passes through a Client Component invocation in development and production.
"use cache"Server Functions #1336Our example currently doesn't demonstrate closure encryption capability:
vite-plugin-react/packages/plugin-rsc/examples/use-cache-callable/callable-cache-plugin.ts
Lines 49 to 54 in fba021f
#1392 indicated we may require core transform change to support it, but apparently framework owned cache runtime wrapper can likely implement this. So the goal of the issue is to such example without changing core transform utils.
1. Demonstrate Protected Inline Captures Without Losing Cache Identity
Source example
An inline cached function can close over values that affect its result:
Current demo: direct captures work
The current callable cache example does not encrypt captures. Its generated shape is conceptually:
This works because the hoisted capture parameter is an ordinary wrapper argument. The wrapper keys both
accountIdandproductId, then invokes the implementation with the same values. The existing development and production E2E proves this direct-capture behavior through Client Component submission.Adding encryption exposes the bug
To prevent a captured value from crossing the client boundary as trusted plaintext, a framework can use the hoister's existing
encodeanddecodehooks. Adding them to the current wrapper arrangement produces this shape:The decode happens too late.
cacheWrapperconstructs its key before calling$$implementation, so it keys the encrypted value rather than the decodedaccountId.Plugin-rsc's AES-GCM encryption generates a fresh random IV for each encryption at packages/plugin-rsc/src/utils/encryption-utils.ts:88. Two requests can therefore render and bind the same logical reader independently but receive different raw cache keys:
The fresh IV is intentional security behavior. Decrypting first reveals that both requests have the same logical key
['account-7', 'product-1'], so the second request can reuse the first entry.Desired framework wrapper behavior
Cache-key construction belongs to the framework-owned cache wrapper, not the generic transform. Given a transform-produced signal that the first argument is a protected capture slot, the wrapper can behave conceptually like this:
The transform-generated module only wires the hoisted implementation and metadata into that framework wrapper:
Now the protected value can cross the client boundary without exposing or trusting plaintext capture data, while both requests use the logical key
['account-7', 'product-1']. This illustration retains the existing implementation-level decode, so the framework also decodes once for cache keying. A tighter generated adapter could avoid duplicate decoding, but the required semantic boundary is the same: the framework wrapper must have decoded captures before it selects a cache entry.Implementation alternatives
The remaining requirement is to improve the callable-cache demo so protected captures are decoded before cache-key construction. This does not necessarily require a new transform primitive. There are two viable implementation levels.
The transform can report that it generated a protected bound slot. PR #1258's
hasBoundArgsis one candidate, while a generated adapter or more general bound-slot description could provide the same static signal:Alternatively, the framework can make its
encoderesult self-describing and let itsdecodehook and cache wrapper share that transport format:The framework controls this reserved envelope, so the wrapper does not need transform metadata to discover the protected slot. Static metadata avoids a wire-level discriminator, while the envelope approach can be implemented with the existing
encodeanddecodehooks plus the publicly exposed encryption runtime. The demo should use the smaller maintainable option rather than adding metadata solely to reproduce Next.js's generated shape.Next.js usage and rationale
Next.js binds one encrypted payload to the registered inline cache wrapper, then decrypts it before constructing cache arguments in use-cache-wrapper.ts:1980. Server graph fixture 58 demonstrates the generated registered wrapper and protected closure binding.
This ordering is required by transported inline cache semantics. Configuring the hoister's
decodehook would decode only inside the private implementation, after the cache wrapper has already selected a key. The Vite-side wrapper, registration, and binding order are detailed in FINDINGS-CACHE-SERVER-REFERENCE-TRANSPORT.md:118.Independent boundary
This work proves that a framework can opt into protected capture transport without changing logical cache identity. It may result in transform metadata or remain entirely in the framework's envelope, encode, decode, and wrapper integration. It does not choose cache handlers, lifetime, invalidation, or storage policy. It also does not require source-parameter admission, stable generated names, method syntax support, or a replacement module lowering strategy.
Verification