Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: ci

on:
push:
branches: [main]
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+-*'
pull_request:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref_type != 'tag' }}

permissions:
contents: write
id-token: write

jobs:
ci:
uses: coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0
secrets:
NPM_CONFIG_FILE: ${{ secrets.NPM_CONFIG_FILE }}
NPM_PACKAGE_REGISTRY: ${{ secrets.NPM_PACKAGE_REGISTRY }}
NPM_PACKAGE_PROXY_REGISTRY: ${{ secrets.NPM_PACKAGE_PROXY_REGISTRY }}
NPM_PACKAGE_REGISTRY_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }}
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,19 @@
## v1.0.0 - 15/05/2026

Initial release of `@coroboros/clone`.

### Features
- `clone<T>(thing, options?)` — deep clone preserving the prototype chain, property descriptors, boxed primitives, native types (`Map`, `Set`, `Date`, `RegExp`, `Error` subclasses, `TypedArray`, `DataView`, `ArrayBuffer`, `Buffer`), and null-prototype objects. Cycle-safe via a `WeakMap` visited cache.
- `freeze<T>(thing)` — recursive deep freeze with a `WeakSet` cycle guard. Skips `ArrayBufferView` instances (`TypedArray`, `DataView`, `Buffer`) since `Object.freeze` throws on them.
- Three granular opt-out flags on `CloneOptions`: `cycles`, `preservePrototype`, `copyDescriptors`. All default `true`. Composing all three `false` gives a fast plain-JSON path competitive with `rfdc`.
- `CloneError` with `code: 'UNSUPPORTED_TYPE'` and `Error.cause` support. `clone` throws it on functions, `Promise`, `Intl.*`, `WeakMap`, `WeakSet`, and constructor functions.
- Browser-safe — the Buffer branch reads `globalThis.Buffer` at module init, so the package works in non-Node bundles.

### Documentation
- README ships with a full API reference, the comparison table vs `structuredClone` / `lodash.cloneDeep` / `rfdc` / `fast-copy`, and the rationale for the prototype + descriptor preservation contract.
- `bench/baseline.md` documents the 1.0.0 numbers across five fixture buckets (flat-10, nested-100, large-1000, with-cycles, class-instances) on Apple M1 / Node 22.22.2.

### Configuration
- TypeScript strict, ES modules + CJS dual build via `tsdown` targeting Node 22 LTS. Zero runtime dependencies.
- `mitata` benchmark suite via `pnpm bench`. `fast-check` property-test suite alongside the unit tests.
- `.github/workflows/ci.yml` calls the `coroboros/ci@v0` reusable workflow with OIDC Trusted Publisher and `npm provenance`.
27 changes: 18 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,43 @@ Global rules (`~/.claude/rules/*`) inherit automatically — tech-standards, wri

## Tech Stack
- TypeScript strict, ES modules + CJS dual build (tsdown)
- Vitest for tests, Biome for lint/format
- Vitest + `fast-check` for property tests, Biome for lint/format
- `mitata` for benchmarks (`pnpm bench`)
- Node.js 22 LTS
- Zero runtime dependencies

## Commands
- `pnpm build` — bundle ESM + CJS + types to `dist/`
- `pnpm test` — run Vitest suite
- `pnpm test` — run Vitest suite (97 tests, incl. property-based)
- `pnpm lint` / `pnpm lint:fix` — Biome check
- `pnpm typecheck` — tsc --noEmit
- `pnpm bench` — build then run `bench/clone.bench.mjs`
- `pnpm dev` — tsdown watch mode

## Important Files
- `src/index.ts` — public entry: `clone`, `freeze`, `CloneOptions`
- `src/index.ts` — public entry: `clone`, `freeze`, `CloneOptions`, `CloneError`, `CloneErrorCode`
- `src/clone.ts` — deep clone with prototype + descriptor preservation, cycle-safe via WeakMap
- `src/freeze.ts` — deep freeze (skips TypedArray + DataView)
- `src/helpers.ts` — internal: `exists`, `is`, `getType` (NOT exported)
- `tests/` — Vitest suites
- `src/freeze.ts` — deep freeze (skips ArrayBufferView via `ArrayBuffer.isView`)
- `src/error.ts` — `CloneError` class with `code` + `cause`
- `src/helpers.ts` — internal: `exists`, `getType`, `primitives` (NOT exported)
- `tests/` — one spec per source module + `clone.property.test.ts` for `fast-check` invariants
- `bench/clone.bench.mjs` — mitata bench vs structuredClone, lodash, rfdc, fast-copy
- `bench/baseline.md` — 1.0.0 numbers + regression budget

## Public API (1.0.0 contract)
- `clone<T>(thing: T, options?: CloneOptions): T` — generic-preserving deep clone
- `freeze<T>(thing: T): T` — recursive deep freeze
- `CloneOptions` — `{ ignoreUndefinedProperties?: boolean }`
- `CloneOptions` — `{ ignoreUndefinedProperties?, cycles?, preservePrototype?, copyDescriptors? }` — all booleans, all default `true` except `ignoreUndefinedProperties` which defaults `false`
- `CloneError` — extends `Error`, exposes `code: CloneErrorCode`, supports `Error.cause`
- `CloneErrorCode` — `'UNSUPPORTED_TYPE'`

## Rules
- **NEVER** break the public API above. The signatures and semantics are the 1.0.0 contract.
- **NEVER** add a runtime dependency. Zero-dep is a feature.
- **NEVER** export `exists`, `is`, `getType` — they are internal helpers only.
- **NEVER** export `exists`, `getType`, `primitives` — they are internal helpers only.
- **NEVER** use `axios`, `request`, or `node-fetch` — use native `fetch` (Node 22+).
- Cycle handling via WeakMap visited-cache is contract — never remove.
- Cycle handling via WeakMap visited-cache is contract — never remove the default.
- Run `pnpm lint && pnpm typecheck && pnpm test` before every commit.
- Run `pnpm bench` against `bench/baseline.md` when touching `src/clone.ts` — no regression > 10 % at fixed feature set.
- Scoped package — `publishConfig.access = "public"` is mandatory, do not remove.
- **Git** — branch `main`; CI owns `npm publish` exclusively (tag-push triggers `ci.yml` → reusable workflow `coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0` → OIDC Trusted Publisher with `npm provenance`, never manual — manual bypasses attestation and the pre-publish gates); run `pnpm lint && pnpm typecheck && pnpm test && pnpm build` locally before tagging; tag MUST equal `package.json` version (the reusable workflow pins `package.json` to the tag automatically); bump via `pnpm version patch|minor`; release body in `gh release create` stays minimal (no install snippet unless the install command changed). All other rules in `@~/.claude/rules/git-conventions.md` apply.
56 changes: 49 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

Deep clones objects while preserving the prototype chain, property descriptors, boxed primitives, and native types (Map, Set, Date, RegExp, Error subclasses, TypedArray, DataView, ArrayBuffer, Buffer). Cycle-safe via a WeakMap visited cache. Deep-freezes recursively, skipping ArrayBuffer views.

[![ci](https://img.shields.io/github/actions/workflow/status/coroboros/clone/ci.yml?branch=main&style=flat-square&color=000000)](https://github.com/coroboros/clone/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/@coroboros/clone?style=flat-square&color=000000)](https://www.npmjs.com/package/@coroboros/clone)
[![branch](https://img.shields.io/badge/branch-stable-000000?style=flat-square)](https://github.com/coroboros/clone)
[![license](https://img.shields.io/badge/license-MIT-000000?style=flat-square)](https://opensource.org/licenses/MIT)
Expand All @@ -17,6 +18,12 @@ Deep clones objects while preserving the prototype chain, property descriptors,

</div>

## Why this exists

`structuredClone` ships in every modern runtime. It strips the prototype chain, so class instances come back as plain objects. It drops property descriptors — non-enumerable fields, accessors, and `configurable: false` flags vanish. Boxed primitives throw. ORM entities, builders, event emitters, frozen state objects, and any custom-constructed value lose information when round-tripped.

`@coroboros/clone` keeps all three. Three opt-out flags trade those guarantees for speed on plain JSON-shaped data, landing in `rfdc`-grade territory without switching libraries.

## Requirements

- Node.js `>=22` LTS. Use [fnm](https://github.com/Schniz/fnm) for version management — Rust-based, faster than nvm.
Expand Down Expand Up @@ -88,10 +95,13 @@ The clone preserves the prototype chain, property descriptors (including non-enu

**Parameters**

| Option | Type | Default | Description |
| ----------------------------------- | --------- | ------------ | ------------------------------------------------------------------------ |
| `thing` | `T` | *(required)* | Value to clone. Any JavaScript value or object. |
| `options.ignoreUndefinedProperties` | `boolean` | `false` | When `true`, omit properties whose value is `undefined`. Recursive. |
| Option | Type | Default | Description |
| ----------------------------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------- |
| `thing` | `T` | *(required)* | Value to clone. Any JavaScript value or object. |
| `options.ignoreUndefinedProperties` | `boolean` | `false` | When `true`, omit properties whose value is `undefined`. Recursive. |
| `options.cycles` | `boolean` | `true` | When `false`, skips the WeakMap visited cache. Caller asserts no cycles. Faster, infinite-recursion if wrong. |
| `options.preservePrototype` | `boolean` | `true` | When `false`, custom objects flatten to plain `{}` (lose `instanceof` and method inheritance). |
| `options.copyDescriptors` | `boolean` | `true` | When `false`, plain objects skip `Reflect.ownKeys` + descriptor walk. Symbol keys and non-enumerable fields drop. Errors keep `message` + `name` only; boxed wrappers keep their value only. |

**Returns** — a deep copy of `thing`, typed as `T`.

Expand All @@ -105,11 +115,12 @@ Native types clone with type-specific semantics:
- `RegExp` — `source` and `flags` preserved.
- `TypedArray` (`Int8Array` through `Float64Array`) — cloned via the constructor.
- `DataView` — buffer copied; `byteOffset` and `byteLength` preserved.
- `Buffer` — bytes copied via `Buffer.allocUnsafe` and `Buffer#copy`.
- `Buffer` — bytes copied via `Buffer.allocUnsafe` and `Buffer#copy`. Browser bundles skip this branch via a runtime guard; the type is Node-only.
- `ArrayBuffer` — sliced.
- `Error` and subclasses (`EvalError`, `RangeError`, `ReferenceError`, `SyntaxError`, `TypeError`, `URIError`) — own properties copied with full descriptors.
- Boxed primitives (`new String()`, `new Number()`, `new Boolean()`) — wrapper recreated with attached properties.
- Custom objects — created via `Object.create(getPrototypeOf(source))`, then own descriptors applied.
- Null-prototype objects (`Object.create(null)`) — preserved with the null prototype.

#### Cycle handling

Expand All @@ -125,9 +136,23 @@ c.self === c; // true

Shared references stay shared. A diamond input produces a diamond output, with each shared subtree cloned exactly once.

#### Fast clone for plain JSON-shaped data

Composing all three opt-out flags gives a `rfdc`-grade fast path for callers who know their data is plain and acyclic:

```ts
const config = clone(largeJsonConfig, {
cycles: false,
preservePrototype: false,
copyDescriptors: false,
});
```

See `bench/baseline.md` for the head-to-head numbers vs `structuredClone`, `lodash.cloneDeep`, `rfdc`, and `fast-copy`.

#### Unsupported types

The following inputs return `undefined`:
The following inputs throw `CloneError` with `code: 'UNSUPPORTED_TYPE'`:

- Functions (sync, async, generator).
- `Promise`.
Expand Down Expand Up @@ -164,9 +189,25 @@ Detection uses `ArrayBuffer.isView(thing)`.
```ts
type CloneOptions = {
ignoreUndefinedProperties?: boolean;
cycles?: boolean;
preservePrototype?: boolean;
copyDescriptors?: boolean;
};
```

### `CloneError`

```ts
class CloneError extends Error {
readonly code: CloneErrorCode;
constructor(code: CloneErrorCode, message: string, options?: { cause?: unknown });
}

type CloneErrorCode = 'UNSUPPORTED_TYPE';
```

Inherits from `Error`. Supports `Error.cause` for wrapping. The `code` field is a stable string discriminant safe for runtime branching.

## Compared to alternatives

| Feature | `structuredClone` | `lodash.cloneDeep` | `rfdc` | `fast-copy` | **`@coroboros/clone`** |
Expand All @@ -179,7 +220,7 @@ type CloneOptions = {
| `Error` subclasses with descriptors | partial | no | no | no | yes |
| Functions, Promises, `WeakMap`, `WeakSet` | no | no | no | no | no (by design) |

The market gap is the prototype chain plus property descriptors. Class instances cloned with `structuredClone` lose their prototype and become plain objects. `lodash.cloneDeep` drops descriptor flags. ORM entities, builders, event emitters, and any custom-constructed state object stay intact through `clone`.
The market gap is the prototype chain plus property descriptors. `structuredClone` strips the prototype from class instances; they return as plain objects. `lodash.cloneDeep` drops descriptor flags. ORM entities, builders, event emitters, and any custom-constructed state object stay intact through `clone`.

## Contributing

Expand All @@ -188,6 +229,7 @@ Bug reports and PRs welcome.
- Open an issue before submitting non-trivial PRs.
- Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
- Run `pnpm lint && pnpm typecheck && pnpm test` before pushing.
- Run `pnpm bench` against `bench/baseline.md` when touching `src/clone.ts` — no regression > 10 % at fixed feature set.
- Target the `main` branch.

## License
Expand Down
93 changes: 93 additions & 0 deletions bench/baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Benchmark baseline

Apple M1, Node 22.22.2. Run `pnpm bench` to reproduce.

The default `clone` preserves cycles, prototype chain, and property descriptors —
the three guarantees no other library on this list provides together. The "fast"
variant turns all three guarantees off and competes with `rfdc` on plain data.

## Post-optim (1.0.0)

### `flat-10` — 10 numeric keys, depth 1

| Implementation | avg/iter |
| ------------------ | ---------: |
| `rfdc()` | 119.94 ns |
| `fast-copy` | 226.09 ns |
| `clone (fast)` | 337.66 ns |
| `lodash.cloneDeep` | 660.23 ns |
| `structuredClone` | 1.12 µs |
| `clone (default)` | 2.57 µs |

### `nested-100` — depth 2, breadth 5 (~30 objects)

| Implementation | avg/iter |
| ------------------ | ---------: |
| `rfdc()` | 479.03 ns |
| `fast-copy` | 948.08 ns |
| `clone (fast)` | 1.04 µs |
| `lodash.cloneDeep` | 2.48 µs |
| `structuredClone` | 3.05 µs |
| `clone (default)` | 7.59 µs |

### `large-1000` — depth 3, breadth 10 (~1110 objects)

| Implementation | avg/iter |
| ------------------ | ---------: |
| `rfdc()` | 15.87 µs |
| `fast-copy` | 27.82 µs |
| `clone (fast)` | 36.72 µs |
| `lodash.cloneDeep` | 78.08 µs |
| `structuredClone` | 75.67 µs |
| `clone (default)` | 287.22 µs |

### `with-cycles` — 3-node cycle (A → B → C → A)

| Implementation | avg/iter | Notes |
| ------------------ | ---------: | ------------------------------------------- |
| `fast-copy` | 562.84 ns | |
| `lodash.cloneDeep` | 1.21 µs | |
| `clone (default)` | 2.36 µs | |
| `rfdc()` | — | not run; default `rfdc()` does not handle cycles |
| `clone (fast)` | — | not run; `cycles: false` overflows on cyclic input by spec |
| `structuredClone` | — | not run; covered by the plain-data buckets |

### `class-instances` — 10 `Pet` instances with non-enumerable descriptor

| Implementation | avg/iter | Notes |
| ------------------ | ---------: | ------------------------------------------- |
| `rfdc()` | 654.58 ns | drops the prototype + the hidden descriptor |
| `clone (fast)` | 863.44 ns | drops the prototype + the hidden descriptor |
| `fast-copy` | 2.31 µs | drops the prototype + the hidden descriptor |
| `lodash.cloneDeep` | 3.09 µs | drops the hidden descriptor |
| `clone (default)` | 7.08 µs | preserves both |
| `structuredClone` | — | throws on class instances with methods |

## Bundle size

| Format | Raw | Gzip |
| ------ | --------: | ---------: |
| ESM | 7.50 kB | 1.93 kB |
| CJS | 7.62 kB | 1.97 kB |

## Why the default path is slower

The "default" column always preserves three things the other libraries either skip
or do not support: the prototype chain, every property descriptor (including
non-enumerable, accessor, and `configurable: false`), and reference cycles. Those
three guarantees compose to call `Object.getPrototypeOf` + `Reflect.ownKeys` +
`Reflect.getOwnPropertyDescriptor` + `Object.defineProperties` on every node.

When the caller drops those guarantees explicitly,
`clone(value, { cycles: false, preservePrototype: false, copyDescriptors: false })`
skips the descriptor walk and uses `for...in`. That is the "fast" column above.
It lands within ~2× of `rfdc` on plain data and beats `lodash.cloneDeep`
on every bucket.

## Going-forward target

**No regression > 10 % on any bucket at fixed feature set.** Deep-clone has more
inherent V8 inline-cache volatility than tight per-element loops; the bar is
loose enough to absorb it without flapping CI. Feature additions that legitimately
cost time (cycle detection, descriptor preservation, etc.) reset the bar for the
buckets they affect.
Loading
Loading