Files
2026-05-27 23:29:53 +01:00

9.2 KiB

Design — StructuredCloneCodec for @suaveplan/cache-storage

Non-normative. Normative requirements live in ./specs/cache-storage/spec.md.

Goal

Add a third Codec<T> implementation to @suaveplan/cache-storage that round-trips the structured-clone-ish subset of JS values — Date, Map, Set, Uint8Array, bigint, RegExp, URL, and arbitrarily nested combinations — over @suaveplan/cbor. The default JsonCodec remains untouched; callers opt in.

Codec interface (unchanged)

// existing — packages/flow/cache-storage/src/codec/codec.types.ts
export interface Codec<T> {
    encode(value: T): Uint8Array;
    decode(bytes: Uint8Array): T;
}

The new codec implements this interface verbatim. No constructor surface beyond the bare class is required for the default case; a future { tags?: ReadonlyMap<number, TagHandler> } option is sketched below but is out of scope for this change.

Tag scheme

CBOR tags from the IANA "Concise Binary Object Representation (CBOR) Tags" registry. Choosing standard tags keeps the on-wire format interop-friendly (a non-JS reader can decode the blob with any RFC 8949 implementation plus the standard tag set).

JS type CBOR tag Payload shape IANA reference
Date 0 RFC 3339 string (date.toISOString()) RFC 8949 §3.4.1
bigint > 0 2 byte string, big-endian magnitude RFC 8949 §3.4.3 (positive bignum)
bigint < 0 3 byte string, big-endian magnitude of -1 - n RFC 8949 §3.4.3 (negative bignum)
URL 32 string (the URL.href) RFC 8949 §3.4.5.3 (URI)
RegExp 35 string (regex.source); flags lost — see "Trade-offs" below RFC 8949 §3.4.5.4 (PCRE regex)
Uint8Array 64 byte string RFC 8746 §3.3
Set 258 array of items (recursively encoded) https://github.com/input-output-hk/cbor-sets-spec
Map 259 array of [key, value] pairs (recursively encoded) https://github.com/shanewholloway/js-cbor-codec/blob/master/docs/CBOR-259-spec--explicit-maps.md

Plain objects, arrays, null, boolean, finite number, and string round-trip natively through CBOR — no transformation, no tag.

Note on the Map choice: RFC 8949 §3.1 major type 5 already carries maps natively, but CBOR maps key on the encoded bytes of the key, which means a JS Map with object keys would either lose distinct identity (every {} encodes to the same bytes) or throw. Tag 259 was introduced by the JS ecosystem specifically to disambiguate "this came from a JS Map and the keys are values, not strings" — picking it preserves round-trip without breaking the major-type-5 invariants for plain-object encoding.

Encode pipeline

input value
  │
  ▼
walk(value)              ── recursive lift
  │   plain JS value     ─► returned as-is
  │   carrier type       ─► returned as new Tagged(tag, walk(payload))
  │   unsupported type   ─► throw CACHE_STORAGE.CODEC_UNSUPPORTED_TYPE
  │
  ▼
encode(walked, { deterministic: false })   ── @suaveplan/cbor
  │
  ▼
Uint8Array

Detection order is intentional — most specific first — so that subclasses do not slip through. Array.isArray is checked before "plain object"; instanceof Date, instanceof Map, etc. come before the duck-typed fallthrough.

Decode pipeline

input bytes
  │
  ▼
decode(bytes, { tagHandling: "registry", tags: TAG_REGISTRY })   ── @suaveplan/cbor
  │
  ▼ each Tagged hit
TagHandler[tag](payload)   ── reverse-lift
  │   known tag    ─► returns the reconstructed JS carrier
  │   unknown tag  ─► throw CACHE_STORAGE.CODEC_UNKNOWN_TAG
  │
  ▼
value

Unknown tags fail loud rather than passing through as Tagged objects. Silent pass-through would change the runtime shape the caller observes versus what they wrote, breaking the decode(encode(x)) deepEquals x contract every other codec honours.

TagHandler shape (from @suaveplan/cbor)

type TagHandler = (payload: DecodedValue) => unknown;

The codec builds a frozen Map<number, TagHandler> once at module load. The registry is internal for this change; a public register(tag, handler) surface is sketched in the "Future work" section below.

Error handling

Two new entries extend CACHE_STORAGE_CODES:

Code Raised by Caller-actionable response
CODEC_UNSUPPORTED_TYPE StructuredCloneCodec.encode when the walk hits Function, Symbol, WeakMap, WeakSet, Promise, or any class instance without a registered tag Switch to JsonCodec (for primitives only) or pre-serialise the value yourself
CODEC_UNKNOWN_TAG StructuredCloneCodec.decode when CBOR yields a Tagged(n, …) for n not in the registry Upgrade the codec version, or pass a custom tag map (future-work surface)

Existing CODEC_ERROR is reused for malformed CBOR bytes (catch-all for CborError thrown by @suaveplan/cbor/decoder) so the boundary remains a single "decode failed" code for back-compat — the new CODEC_UNKNOWN_TAG covers only the well-formed-but-unrecognised case.

Fallback strategy when CBOR decoder hits an unknown tag

The codec configures @suaveplan/cbor's decoder with a TagRegistry containing handlers for the eight tags above. For any other tag number, the registry returns a Tagged placeholder; the codec then inspects the decoded tree post-hoc — any leftover Tagged instance triggers CACHE_STORAGE.CODEC_UNKNOWN_TAG carrying the offending tag number in the error context. Walking the tree once for the post-check is O(n) over the decoded value count and runs at most once per decode call.

Why not configure the cbor decoder to throw immediately on unknown tags? Because @suaveplan/cbor's tag-handling mode is a single decoder-wide flag; flipping it to "throw" would also kill any caller who later wants to mix this codec with a custom fallback handler. Inspecting post-hoc keeps the policy local to this codec.

File layout

packages/flow/cache-storage/src/codec/
  codec.types.ts              # unchanged
  identity.ts                 # unchanged
  identity.test.ts            # extended with conformance harness call
  identity.md                 # unchanged
  json.ts                     # unchanged
  json.test.ts                # extended with conformance harness call
  json.md                     # unchanged
  structured-clone.ts         # NEW — codec implementation (~180 LoC)
  structured-clone.test.ts    # NEW — conformance + negative cases
  structured-clone.md         # NEW — sibling docs
  conformance.ts              # NEW — runCodecConformance harness
  conformance.test.ts         # NEW — harness self-test
  conformance.md              # NEW — sibling docs
  index.ts                    # extended — explicit named re-export added

The 300-line soft cap is comfortably met. If the lift table grows (custom-tag registration), structured-clone.tags.ts extracts cleanly.

Trade-offs

  • RegExp.flags is dropped on round-trip. RFC 8949's PCRE-regex tag carries only the pattern string; flags are not part of the registry payload spec. Documented in the sibling .md. Callers needing flags can wrap the regex in { source, flags } and cache that.
  • number === NaN / ±Infinity survive CBOR but not JSON. With StructuredCloneCodec these round-trip cleanly (CBOR has float specials); JsonCodec already turns them into null. Worth noting in docs but not a defect of this codec.
  • No transferable objects. ArrayBuffer.transfer, MessagePort, ImageBitmap are out of scope — they belong to browser-only structured clone, not a universal-tier cache codec.
  • CBOR map key constraint. A JS Map with object keys round-trips because we use tag 259 (array of [k, v] pairs), where each k is recursively encoded as a full CBOR item. The major-type-5 "duplicate keys collide" hazard does not apply.

Future work (explicitly deferred)

  • Public StructuredCloneCodec constructor option { tags?: ReadonlyMap<number, { encode?, decode? }> } so callers can register their own carriers (e.g. Decimal, Big, domain value objects).
  • Promotion of runCodecConformance to @suaveplan/testing if a second consumer adopts the Codec<T> interface.
  • Streaming variant StructuredCloneStreamCodec for >10 MiB blobs once a real consumer asks. Current synchronous design is the right default.