Skip to main content

Lumenize Structured Clone

Zero-dependency serialization with full-fidelity type support for complex JavaScript types including cycles, Errors, Request/Response, special numbers, and more.

Why This Package?

When building RPC systems, storing data, or moving data over the network, you need to serialize complex JavaScript objects. JSON alone isn't enough:

  • Lost Types: JSON loses Date, Map, Set, Error objects
  • No Circular or Aliased References: JSON can't handle cyclic or aliasesed structure
  • No Special Numbers: JSON converts NaN and Infinity to null
  • No Web API Objects: JSON can't serialize Request, Response, Headers, URL

This package solves all of these problems.

Installation

npm install @lumenize/structured-clone

Supported Types

While this package was originally optimized for the Cloudflare edge compute platform, nothing in it is Cloudflare-specific. All tests run in three environments: Node.js, headless Chromium browser, and simulated Cloudflare Workers. Our primary use case includes browser clients, and the full test suite passes in all environments.

Not a Cloudflare user? Feel free to ignore the first three columns in the comparison table below. This package provides comprehensive structured cloning with better Error handling, special number support, and Web API object serialization—useful in any JavaScript environment.

TypeWorkers RPCDO StorageCap'n WebLumenizeNotes
Cycles & Aliases✅→❌✅→❌Cap'n Web throws error
Cloudflare plans to remove
Primitives
undefined
null
Special Numbers
NaN❌→✅Cap'n Web returns null
Infinity❌→✅Cap'n Web returns null
-Infinity❌→✅Cap'n Web returns null
Built-in Types
BigInt
Date
RegExp❌→✅
Map❌→✅
Set❌→✅
ArrayBuffer❌→✅
Uint8Array
Errors
Error (thrown)N/A⚠️Cap'n Web loses name and remote stack
Error (value)⚠️⚠️→✅Cap'n Web loses name and remote stack
Web API Types
Request
Response
Headers❌→✅Cap'n Web: type annotation only, no serialization
URL❌→✅
Streams
ReadableStreamCap'n Web: "may be added"
WritableStreamLumenize: "just use WebSockets"

Note, we use ❌→✅ or ⚠️→✅ in the Cap'n Web column for certain rows because we have submitted a pull request to upgrade Cap'n Web's type support.

Key Features

  • Platform Independent: Works in Node.js, browsers, Deno, Bun, and Cloudflare Workers
  • Full Type Support: All primitives, special numbers (NaN, Infinity, -Infinity), built-in types (Date, RegExp, Map, Set), typed arrays, and Web API objects
  • Error Fidelity: Preserves stack traces, cause chains, custom properties, and subclass types (including custom Error classes!)
  • Circular References: Automatically handled with $lmz reference markers
  • Zero Dependencies: No external packages required
  • Human-Readable Format: Tuple-based ["type", data] format for debugging
  • Async API: Supports Request/Response body reading

Basic Usage

The core API is simple: stringify() to serialize, parse() to deserialize.

Note: Both functions are async to support Request/Response body reading.

import { stringify, parse } from '@lumenize/structured-clone';

const user = {
name: 'Alice',
age: 30,
active: true
};

const serialized = await stringify(user);
const restored = await parse(serialized);

expect(restored).toEqual(user);

This works with complex nested structures, arrays, and all types shown in the table above.

Key Features with Examples

Error Chains with cause

Error chaining is fully preserved, including nested causes:

const networkError = new Error('Connection timeout');
const appError = new Error('Failed to fetch user data', {
cause: networkError
});

const restored = await parse(await stringify(appError));

expect(restored.message).toBe('Failed to fetch user data');
expect(restored.cause).toBeInstanceOf(Error);
expect(restored.cause.message).toBe('Connection timeout');

Custom Error Properties

Add arbitrary properties to Errors—they're all preserved:

const apiError: any = new Error('API request failed');
apiError.statusCode = 500;
apiError.endpoint = '/api/users';

const restored: any = await parse(await stringify(apiError));

expect(restored.message).toBe('API request failed');
expect(restored.statusCode).toBe(500);

Error Subclass Preservation

All standard JavaScript Error types are automatically preserved with correct instanceof behavior:

Standard Error Types (Built-in):

  • Error - Base error type
  • TypeError - Type-related errors
  • RangeError - Value out of range
  • ReferenceError - Invalid references
  • SyntaxError - Syntax parsing errors
  • URIError - URI encoding/decoding errors
  • EvalError - Eval-related errors
  • AggregateError - Multiple errors combined

Example:

const typeError = new TypeError('Expected string, got number');
const restored = await parse(await stringify(typeError));

expect(restored).toBeInstanceOf(TypeError); // ✅ Type preserved!
expect(restored).toBeInstanceOf(Error); // ✅ Also an Error
expect(restored.name).toBe('TypeError');
expect(restored.message).toBe('Expected string, got number');

How it works:

We use dynamic constructor lookup via globalThis[error.name]:

  1. Error is serialized with its name property (e.g., "TypeError")
  2. During deserialization, we look up globalThis["TypeError"]
  3. If found, we create a new instance of that constructor
  4. If not found, we fall back to base Error (but preserve the name property)

Custom Error Classes:

Custom Error classes work too, but require explicit global registration since module imports are not in global scope:

// Your custom Error class (in a module)
export class ValidationError extends Error {
name = 'ValidationError';
constructor(message: string, public field: string) {
super(message);
}
}

To enable type preservation, register it globally:

// Register on globalThis so deserializer can find it
(globalThis as any).ValidationError = ValidationError;

Now serialization preserves the type:

const error = new ValidationError('Invalid email', 'email');
const restored = await parse(await stringify(error));

// ...
expect(restored instanceof ValidationError).toBe(true); // ✅ true!
expect((restored as any).field).toBe('email'); // ✅ 'email' (custom property preserved)
expect(restored.name).toBe('ValidationError'); // ✅ 'ValidationError'

Without global registration:

If you don't register the class globally, deserialization falls back to base Error but still preserves custom properties and the name:

// No global registration
const error = new ValidationError('Invalid email', 'email');
const restored = await parse(await stringify(error));

expect(restored instanceof ValidationError).toBe(false); // ❌ false (no type)
expect(restored instanceof Error).toBe(true); // ✅ true (fallback)
expect((restored as any).field).toBe('email'); // ✅ 'email' (property still preserved!)
expect(restored.name).toBe('ValidationError'); // ✅ 'ValidationError' (name preserved)

Best Practice: Register custom Error classes in your app's initialization code, before any deserialization happens. This ensures full type preservation across serialization boundaries.

Web API Objects Example

Serialize Request/Response objects for storage, queues, or RPC:

const apiData = {
incomingRequest: new Request('https://api.example.com/endpoint', {
method: 'POST',
body: 'request data'
}),
outgoingResponse: new Response('response data', {
status: 200
}),
timestamp: Date.now()
};

const restored = await parse(await stringify(apiData));

expect(restored.incomingRequest).toBeInstanceOf(Request);
// ...

Note: Request/Response bodies are consumed during serialization (streams become strings). You should clone the Request/Response if you need them preserved.

Advanced API

The package provides a three-tier API for different use cases:

Tier 1: stringify() / parse() - JSON String I/O

The main API for most use cases. Serializes to/from JSON strings:

const jsonString = await stringify(complexObject);  // Returns JSON string
const restored = await parse(jsonString); // Reconstructs from JSON string

Use for: HTTP requests/responses, file storage, logging

Tier 2: preprocess() / postprocess() - Intermediate Format

For scenarios where you want the structured object format without JSON stringification:

const intermediate = await preprocess(complexObject);  // Returns { root, objects }
const restored = await postprocess(intermediate); // Reconstructs from object

Use for: MessagePort, BroadcastChannel, IndexedDB, or when you need custom JSON handling

Tier 3: encode*() / decode*() - Type-Specific Encoding

For encoding specific Web API types when you need explicit control:

// Encode Request for storage or transmission
const encoded = await encodeRequest(request); // Plain object
// Later... decode back to Request
const decoded = decodeRequest(encoded); // Reconstructed Request

Use for: Embedding Request/Response in larger structures that are separately serialized (e.g., Durable Object storage, Cloudflare Queues)

Example - DO Storage:

// Store Request in DO storage
const encodedRequest = await encodeRequest(request);
ctx.storage.kv.put('my-request', {
timestamp: Date.now(),
request: encodedRequest // Plain object, storage handles serialization
});

// Later... retrieve and decode
const stored = ctx.storage.kv.get('my-request');
const restoredRequest = decodeRequest(stored.request);

Performance

The tuple-based $lmz format is designed for efficiency:

  • Fast serialization - optimized two-pass algorithm with cycle detection
  • Fast parsing - efficient reconstruction of complex structures
  • Human-readable format for debugging: ["type", data] with ["$lmz", ref] for cycles/aliases

Limitations

  • Symbols: Cannot be serialized (throws TypeError)
  • Functions: Converted to internal markers (preserved structure, not executable)
  • Signed Zero: -0 becomes +0 (What is -0 anyway?)
  • Streams: ReadableStream/WritableStream not supported

License

MIT License © 2025 Larry Maccherone

Attribution

Inspired by:

See ATTRIBUTIONS.md for full attribution details.