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
Mapwith object keys would either lose distinct identity (every{}encodes to the same bytes) or throw. Tag259was 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.flagsis 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/±Infinitysurvive CBOR but not JSON. WithStructuredCloneCodecthese round-trip cleanly (CBOR has float specials);JsonCodecalready turns them intonull. Worth noting in docs but not a defect of this codec.- No transferable objects.
ArrayBuffer.transfer,MessagePort,ImageBitmapare out of scope — they belong to browser-only structured clone, not a universal-tier cache codec. - CBOR map key constraint. A JS
Mapwith object keys round-trips because we use tag259(array of[k, v]pairs), where eachkis recursively encoded as a full CBOR item. The major-type-5 "duplicate keys collide" hazard does not apply.
Future work (explicitly deferred)
- Public
StructuredCloneCodecconstructor option{ tags?: ReadonlyMap<number, { encode?, decode? }> }so callers can register their own carriers (e.g.Decimal,Big, domain value objects). - Promotion of
runCodecConformanceto@suaveplan/testingif a second consumer adopts theCodec<T>interface. - Streaming variant
StructuredCloneStreamCodecfor >10 MiB blobs once a real consumer asks. Current synchronous design is the right default.