Skip to content

Conversation

@dmmulroy
Copy link

@dmmulroy dmmulroy commented Dec 5, 2025

Summary

Fixes #99 - @Persist state returned RpcStub objects instead of serialized data.

Problem: Cloudflare RPC uses structured clone which can't serialize Proxy objects. @Persist wraps values in Proxy for mutation tracking, causing RPC callers to receive RpcStub objects.

Solution: Unwrap proxies before RPC serialization. Optimized to skip non-proxied values for performance.

Key changes

// persist.ts - Added fast path for non-proxied values
function containsProxy(value: unknown): boolean {
  // Recursively checks for IS_PROXIED symbol
}

export function unwrapProxy<T>(value: T): T {
  // Fast path: no proxy = return original unchanged
  if (!containsProxy(value)) return value;
  
  // Only recreate with null prototype when proxy detected
  // ...
}
// index.ts - Wrap RPC methods to unwrap proxy returns
private _wrapMethodsForRpc(): void {
  // Wraps public methods to call unwrapProxy() on return values
  // and is effectively free for non-persisted returns
}

Test plan

  • Unit tests for unwrapProxy behavior
  • Tests for proxy detection fast path
  • Tests for prototype pollution prevention (proxied objects only)
  • Manual test with reproduction repo

@dmmulroy dmmulroy force-pushed the fix/persist-rpc-stub branch from 798db8d to da0f7d8 Compare December 5, 2025 17:07
@dmmulroy dmmulroy marked this pull request as draft December 5, 2025 17:34
Fixes cloudflare#99 - @persist state returned RpcStub objects instead of data

Problem: Cloudflare RPC uses structured clone which can't serialize
Proxy objects. @persist wraps values in Proxy for mutation tracking.

Solution: Unwrap proxies before RPC serialization via _wrapMethodsForRpc().
Optimized to skip non-proxied values (most RPC returns) for performance.

Key changes in persist.ts:
- Add containsProxy() to detect IS_PROXIED symbol recursively
- unwrapProxy() returns original when no proxy detected (fast path)
- Only recreate objects with null prototype when proxy present

```typescript
// Before: always recreated all objects
export function unwrapProxy<T>(value: T): T {
  // ... always traverse and recreate with Object.create(null)
}

// After: fast path for non-proxied values
export function unwrapProxy<T>(value: T): T {
  if (!containsProxy(value)) return value; // identity return
  // ... only unwrap when proxy detected
}
```
@dmmulroy dmmulroy force-pushed the fix/persist-rpc-stub branch from da0f7d8 to a624cd4 Compare December 5, 2025 17:44
Changed Object.create(null) to {} so returned objects retain
standard prototype methods (hasOwnProperty, toString, etc).

Updated tests to verify malicious keys aren't copied as own
properties while allowing inherited prototype methods.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@dmmulroy dmmulroy marked this pull request as ready for review December 5, 2025 18:12
@apankov1
Copy link

What would it take to merge this fix and update the library? I've run into the same issue and now porting the fix into my fork of the library with other fixes. I am in no rush, just curious. Thank you

apankov1 added a commit to apankov1/actors that referenced this pull request Dec 10, 2025
Allows consumers to manually unwrap @persist proxies for
structuredClone and other non-RPC serialization use cases.

Related to cloudflare#100
@apankov1
Copy link

While testing this fix in my project, I ran into cases where auto-unwrapping doesn't apply — structuredClone() on @persist fields and manual caching to DO storage still fail with DataCloneError. Exporting unwrapProxy from the public API covers these: apankov1/actors@c8b730c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@Persist state returns RpcStub objects when returned via RPC

3 participants