Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# `data`
Data manipulation, masking, deduplication, diffing, and byte utilities.
<!-- begin:auto-docs -->
## Packages in `data`
17 packages.
| Package | Description |
|---|---|
| [`@suaveplan/bytes`](./bytes/README.md) | Low-level byte utilities for Suaveplan: lossless hex/base64/UTF-8 encoding, `Uint8Array` manipulation, and UUID conversion. No native addons; depends only on p… |
| [`@suaveplan/clipboard`](./clipboard/README.md) | Enterprise Clipboard API abstraction for browser-tier consumers. Wraps the async Clipboard API with a textarea + `execCommand` fallback, a paste-event read fal… |
| [`@suaveplan/data-masking`](./data-masking/README.md) | PII/sensitive data masking for objects and strings with field-level rules, nested traversal, eight masking strategies, and OpenTelemetry instrumentation. |
| [`@suaveplan/dedup`](./dedup/README.md) | Entity deduplication engine for detecting and merging duplicate records in any data set. Given a list of objects with varying representations of the same real-… |
| [`@suaveplan/diff`](./diff/README.md) | Zero-dependency RFC 6902 JSON Patch engine for computing, applying, and inverting structural differences between JSON documents. The `diff(a, b)` function prod… |
| [`@suaveplan/json`](./json/README.md) | Safe, enterprise-grade JSON utilities with prototype-pollution protection, circular-reference detection, and OpenTelemetry instrumentation. |
| [`@suaveplan/media`](./media/README.md) | Universal media detection, metadata extraction, and constraint validation. Combines a pluggable magic-byte `SignatureRegistry` with the MIME database from… |
| [`@suaveplan/mime`](./mime/README.md) | Structured MIME-type detection, parsing, and registry for the Suaveplan platform. Provides RFC 7231 Content-Type parsing, magic-byte file signature detection,… |
| [`@suaveplan/packer`](./packer/README.md) | Binary packing/unpacking utilities. Two modes: |
| [`@suaveplan/packer-browser`](./packer-browser/README.md) | Browser-tier runtime for `@suaveplan/packer` archives. Ships a Service Worker interceptor, a `PackedResourceManager` (LRU + Blob-URL bookkeeping + ref-count +… |
| [`@suaveplan/pagination`](./pagination/README.md) | Universal pagination primitives for cursor-based, offset-based, and keyset-based (seek) pagination strategies. The package covers the full pagination surface:… |
| [`@suaveplan/pwa`](./pwa/README.md) | Enterprise browser-tier PWA primitives for Suaveplan applications. |
| [`@suaveplan/react-packer`](./react-packer/README.md) | React 19 companion for `@suaveplan/packer-browser`. Ships `usePackedResource`, `usePackedImage`, `usePackedAudio`, `usePackedVideo`, and `usePackedFont` hooks… |
| [`@suaveplan/serialize`](./serialize/README.md) | Round-trip-preserving structured serialization for JavaScript's rich type system. The standard `JSON.stringify`/`JSON.parse` pair silently drops `Date` objects… |
| [`@suaveplan/templates`](./templates/README.md) | A plugin-based string template engine for TypeScript. Resolves `{variable}` and `{variable:transform}` tokens in strings using a priority-sorted, regex-scan pi… |
| [`@suaveplan/validation`](./validation/README.md) | Runtime schema validation with a Zod-compatible API. This package is a self-contained parse engine — it does not import Zod or any other validation library — s… |
| [`@suaveplan/web-workers`](./web-workers/README.md) | Enterprise browser-tier runtime for managing Web Workers, SharedWorkers, and fixed-size worker pools with typed RPC, AbortSignal-aware cancellation, transfer-l… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Data manipulation, masking, deduplication, diffing, and byte utilities.

View File

@@ -0,0 +1,95 @@
# @suaveplan/bytes - Low-level byte utilities: hex/base64/utf8 encoding, Uint8Array helpers, UUID conversion
## Summary
Low-level byte utilities for Suaveplan: lossless hex/base64/UTF-8 encoding, `Uint8Array` manipulation, and UUID conversion. No native addons; depends only on platform globals (`TextEncoder`, `atob`/`btoa`, `crypto.randomUUID`) and `@suaveplan/error` for typed errors.
<!-- begin:auto-docs -->
## Documentation tree
- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_ - [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_ - [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/bytes
```
## Quick Start
```ts
import {
bytesToHex, hexToBytes,
bytesToBase64, base64ToBytes,
bytesToUtf8, utf8ToBytes,
concatUint8Arrays,
convertDataFormat,
generateUuid, uuidToUint8Array, uint8ArrayToUuid,
} from "@suaveplan/bytes";
// Encoding
const raw = utf8ToBytes("Hello"); // Uint8Array [72,101,108,108,111]
const hex = bytesToHex(raw); // "48656c6c6f"
const b64 = bytesToBase64(raw); // "SGVsbG8="
const text = bytesToUtf8(raw); // "Hello"
// Uint8 helpers
const frame = concatUint8Arrays(raw, new Uint8Array([0x21]));
// Unified conversion
const asBase64 = convertDataFormat("48656c6c6f", "base64", "hex"); // "SGVsbG8="
// UUID
const id = generateUuid(); // "550e8400-e29b-41d4-a716-446655440000"
const bytes = uuidToUint8Array(id); // Uint8Array (16 bytes)
const back = uint8ArrayToUuid(bytes); // → id
```
## Modules
| Module | Description | Docs |
|--------|-------------|------|
| `@suaveplan/bytes/encoding` | Hex, base64, UTF-8 encode/decode | [encoding.md](src/encoding/encoding.md) |
| `@suaveplan/bytes/uint8` | Uint8Array utilities and format conversion | [uint8.md](src/uint8/uint8.md) |
| `@suaveplan/bytes/uuid` | UUID generation and binary conversion | [uuid.md](src/uuid/uuid.md) |
| `@suaveplan/bytes/errors` | `BytesError`, `BYTES_CODES` | — |
| `@suaveplan/bytes/schemas` | Zod schemas (HexStringSchema, etc.) | — |
## Benchmarks
Run with `bun src/__benchmarks__/encoding.bench.ts`.
```text
clk: ~2.74 GHz
cpu: Intel(R) Xeon(R) CPU E5-2696 v4 @ 2.20GHz
runtime: bun 1.3.13 (x64-linux)
benchmark avg (min … max)
bytesToHex (32 bytes) 9.31 µs/iter
bytesToHex (64 KB) 5.42 ms/iter
hexToBytes (32 bytes) 7.43 µs/iter
bytesToBase64 (32 bytes) 4.29 µs/iter
bytesToBase64 (64 KB) 983.62 µs/iter
```
## Error Codes
| Code | Thrown by |
|------|-----------|
| `BYTES.ENCODE.INVALID_HEX` | `hexToBytes` — odd length or non-hex chars |
| `BYTES.ENCODE.INVALID_BASE64` | `base64ToBytes` — invalid base64 alphabet |
| `BYTES.ENCODE.INVALID_UTF8` | Reserved |
| `BYTES.UINT8.INVALID_INPUT` | `inputToUint8Array` — unknown input type |
| `BYTES.UINT8.FORMAT_MISMATCH` | `validateInputTypeFormat` — type/format clash |
| `BYTES.UUID.INVALID_FORMAT` | `uuidToUint8Array` — malformed UUID string |
| `BYTES.UUID.INVALID_LENGTH` | `uint8ArrayToUuid` — not 16 bytes |

View File

@@ -0,0 +1,94 @@
{
"name": "@suaveplan/bytes",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Low-level byte utilities: hex/base64/utf8 encoding, Uint8Array helpers, UUID conversion",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./encoding": {
"import": {
"types": "./dist/encoding/index.d.ts",
"default": "./dist/encoding/index.js"
}
},
"./uint8": {
"import": {
"types": "./dist/uint8/index.d.ts",
"default": "./dist/uint8/index.js"
}
},
"./uuid": {
"import": {
"types": "./dist/uuid/index.d.ts",
"default": "./dist/uuid/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,103 @@
# encoding
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `encoding` module provides lossless, bidirectional conversion between raw `Uint8Array` byte sequences and three ubiquitous text representations: lowercase hexadecimal, standard base64, and UTF-8. It is the lowest-level I/O surface in `@suaveplan/bytes` and is intentionally free of any dependency beyond `@opentelemetry/api` and the `@suaveplan/error` error substrate.
Every conversion function validates its input strictly and throws a typed `BytesError` on malformed data, making it safe to use directly in protocol parsers, cryptographic pipelines, and data serialisation layers without additional guard code at the call site.
## Features
- **Hex encoding/decoding** — `bytesToHex` emits lowercase two-character hex pairs; `hexToBytes` accepts both upper- and lowercase and rejects odd-length or non-hex inputs with `BYTES.ENCODE.INVALID_HEX`.
- **Base64 encoding/decoding** — `bytesToBase64` uses the standard (non-URL-safe) alphabet; `base64ToBytes` validates the input against the standard alphabet before delegating to `atob`, rethrowing any decode error as `BYTES.ENCODE.INVALID_BASE64`.
- **UTF-8 encoding/decoding** — `utf8ToBytes` uses `TextEncoder` and `bytesToUtf8` uses `TextDecoder`, both handling multi-byte Unicode code points correctly.
- **OTel tracing** — every exported function wraps its body in a named active span under the `@suaveplan/bytes` tracer so encoding operations appear in distributed traces.
- **Zod schemas** — `HexStringSchema` and `Base64StringSchema` are exported from `encoding.schemas.ts` for use in Zod-based validation pipelines.
- **Property-tested** — fast-check verifies lossless round-trips for arbitrary byte sequences for all three codecs.
## Usage: Basic Example
```ts
import { bytesToHex, hexToBytes, bytesToBase64, base64ToBytes, utf8ToBytes, bytesToUtf8 } from "@suaveplan/bytes/encoding";
const raw = new Uint8Array([72, 101, 108, 108, 111]);
// Hex
const hex = bytesToHex(raw); // "48656c6c6f"
const back = hexToBytes(hex); // Uint8Array [72, 101, 108, 108, 111]
// Base64
const b64 = bytesToBase64(raw); // "SGVsbG8="
const back2 = base64ToBytes(b64); // Uint8Array [72, 101, 108, 108, 111]
// UTF-8
const text = bytesToUtf8(raw); // "Hello"
const back3 = utf8ToBytes(text); // Uint8Array [72, 101, 108, 108, 111]
```
## Usage: Advanced Example
```ts
import { hexToBytes, bytesToBase64, BytesError } from "@suaveplan/bytes";
// Safely parse an untrusted hex payload from an HTTP header
function parseHexHeader(value: string): Uint8Array {
try {
return hexToBytes(value.trim().toLowerCase());
} catch (err) {
if (err instanceof BytesError) {
// err.code === "BYTES.ENCODE.INVALID_HEX"
throw new Error(`Malformed X-Signature header: ${err.message}`);
}
throw err;
}
}
// Convert the raw bytes to base64 for JSON transport
const signature = parseHexHeader("deadbeef");
const forJson = bytesToBase64(signature); // "3q2+7w=="
```
## API Reference
### `bytesToHex(bytes: Uint8Array): string`
Converts `bytes` to a lowercase hexadecimal string. Each byte becomes exactly two hex characters. Returns `""` for an empty array.
### `hexToBytes(hex: string): Uint8Array`
Converts a hex string to a `Uint8Array`. Accepts upper- and lowercase characters. Returns an empty array for an empty string.
**Throws** `BytesError(BYTES.ENCODE.INVALID_HEX)` when:
- `hex.length` is odd.
- `hex` contains characters outside `[0-9a-fA-F]`.
### `bytesToBase64(bytes: Uint8Array): string`
Converts `bytes` to a standard base64 string (with `=` padding). Returns `""` for an empty array.
### `base64ToBytes(base64: string): Uint8Array`
Converts a standard base64 string to a `Uint8Array`. Returns an empty array for an empty string.
**Throws** `BytesError(BYTES.ENCODE.INVALID_BASE64)` when `base64` contains characters outside the standard base64 alphabet or the decode otherwise fails.
### `bytesToUtf8(bytes: Uint8Array): string`
Decodes `bytes` as a UTF-8 string using `TextDecoder`. Invalid sequences are replaced with the Unicode replacement character (U+FFFD) per platform behaviour.
### `utf8ToBytes(str: string): Uint8Array`
Encodes `str` as UTF-8 bytes using `TextEncoder`. Every Unicode code point, including surrogate pairs and multi-byte characters, is encoded correctly.
## Implementation Notes
The hex codec avoids regular-expression replacement in the hot encode path in favour of a simple loop over byte values with `toString(16).padStart(2, "0")`, which is both readable and cache-friendly on V8/JavaScriptCore. The decode path uses a single `HEX_RE.test()` validation pass before looping — this avoids per-character error checking while still catching all invalid inputs in one branch.
The base64 codec delegates to the platform `btoa`/`atob` globals (available natively in Node 18+ and Bun), avoiding any third-party encoding library. The encode path materialises a binary string from the byte array; the decode path pre-validates with a regex before calling `atob` and wraps any thrown exception in a `BytesError` so callers never see a raw `DOMException`.
OTel spans are started synchronously and ended in `finally` blocks, ensuring spans are always closed even when an exception propagates. The tracer is module-level so instrumentation is zero-cost when no SDK is installed (the `@opentelemetry/api` noop tracer is used automatically).

View File

@@ -0,0 +1,25 @@
import z from "zod";
/**
* Zod schema for a valid hexadecimal string.
*
* Accepts any even-length string consisting solely of hexadecimal digits
* (0–9, a–f, A–F). An empty string is valid and represents zero bytes.
*/
export const HexStringSchema = z
.string()
.refine((s) => s.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(s), {
message: "Must be an even-length hexadecimal string",
});
/**
* Zod schema for a valid standard base64 string.
*
* Accepts the standard base64 alphabet (A–Z, a–z, 0–9, +, /) with optional
* `=` padding at the end. An empty string is valid.
*/
export const Base64StringSchema = z
.string()
.refine((s) => /^[A-Za-z0-9+/]*={0,2}$/.test(s), {
message: "Must be a valid base64-encoded string",
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "./encoding.js";
describe("bytesToHex / hexToBytes", () => {
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
const input = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
expect(hexToBytes(bytesToHex(input))).toEqual(input);
});
it("bytesToHex produces lowercase hex", { timeout: 5000 }, () => {
expect(bytesToHex(new Uint8Array([255]))).toBe("ff");
});
it("bytesToHex handles empty array", { timeout: 5000 }, () => {
expect(bytesToHex(new Uint8Array())).toBe("");
});
it("hexToBytes handles empty string", { timeout: 5000 }, () => {
expect(hexToBytes("")).toEqual(new Uint8Array());
});
it("hexToBytes rejects odd-length string", { timeout: 5000 }, () => {
expect(() => hexToBytes("abc")).toThrow();
});
it("hexToBytes rejects non-hex chars", { timeout: 5000 }, () => {
expect(() => hexToBytes("zz")).toThrow();
});
it("hexToBytes rejects uppercase letters that are not hex", {
timeout: 5000,
}, () => {
expect(() => hexToBytes("GG")).toThrow();
});
it("hexToBytes accepts uppercase hex letters", { timeout: 5000 }, () => {
expect(hexToBytes("FF")).toEqual(new Uint8Array([255]));
});
it("hex round-trip for arbitrary bytes", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes);
})
);
});
});
describe("bytesToBase64 / base64ToBytes", () => {
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
const input = new Uint8Array([1, 2, 3, 4, 5]);
expect(base64ToBytes(bytesToBase64(input))).toEqual(input);
});
it("handles empty array", { timeout: 5000 }, () => {
expect(bytesToBase64(new Uint8Array())).toBe("");
expect(base64ToBytes("")).toEqual(new Uint8Array());
});
it("rejects invalid base64", { timeout: 5000 }, () => {
expect(() => base64ToBytes("not!valid@base64")).toThrow();
});
it("rejects syntactically valid but malformed base64 (bad padding)", {
timeout: 5000,
}, () => {
// "a=" passes the regex but atob rejects it (requires groups of 4 chars)
expect(() => base64ToBytes("a=")).toThrow();
});
it("base64 round-trip for arbitrary bytes", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
})
);
});
});
describe("bytesToUtf8 / utf8ToBytes", () => {
it("round-trips ASCII", { timeout: 5000 }, () => {
const text = "Hello, world!";
expect(bytesToUtf8(utf8ToBytes(text))).toBe(text);
});
it("round-trips multibyte characters", { timeout: 5000 }, () => {
const text = "こんにちは";
expect(bytesToUtf8(utf8ToBytes(text))).toBe(text);
});
it("handles empty string", { timeout: 5000 }, () => {
expect(bytesToUtf8(utf8ToBytes(""))).toBe("");
});
it("utf8 round-trip for arbitrary strings", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.string(), (str) => {
expect(bytesToUtf8(utf8ToBytes(str))).toBe(str);
})
);
});
});

View File

@@ -0,0 +1,162 @@
import { trace } from "@opentelemetry/api";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** Regular expression matching a valid hex string (even-length, hex chars only). */
const HEX_RE = /^[0-9a-fA-F]*$/;
/** Regular expression matching a valid base64 string (standard alphabet + padding). */
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
/**
* Encode a `Uint8Array` to a lowercase hexadecimal string.
*
* An empty array returns an empty string.
*/
export function bytesToHex(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.encoding.bytesToHex", (span) => {
try {
let result = "";
for (let i = 0; i < bytes.length; i++) {
result += (bytes[i] as number).toString(16).padStart(2, "0");
}
return result;
} finally {
span.end();
}
});
}
/**
* Decode a hexadecimal string (case-insensitive) to a `Uint8Array`.
*
* Throws `BytesError` with code `BYTES.ENCODE.INVALID_HEX` when the input
* contains non-hex characters or has an odd length.
* An empty string returns an empty `Uint8Array`.
*/
export function hexToBytes(hex: string): Uint8Array {
return tracer.startActiveSpan("bytes.encoding.hexToBytes", (span) => {
try {
if (hex.length === 0) {
return new Uint8Array();
}
if (hex.length % 2 !== 0) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
`Invalid hex string: odd length (${hex.length}). Hex strings must have an even number of characters.`
);
}
if (!HEX_RE.test(hex)) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
"Invalid hex string: contains non-hexadecimal characters."
);
}
const result = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
result[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
}
return result;
} finally {
span.end();
}
});
}
/**
* Encode a `Uint8Array` to a standard base64 string.
*
* An empty array returns an empty string.
*/
export function bytesToBase64(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.encoding.bytesToBase64", (span) => {
try {
if (bytes.length === 0) {
return "";
}
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary);
} finally {
span.end();
}
});
}
/**
* Decode a standard base64 string to a `Uint8Array`.
*
* Throws `BytesError` with code `BYTES.ENCODE.INVALID_BASE64` when the input
* is not a valid base64 string.
* An empty string returns an empty `Uint8Array`.
*/
export function base64ToBytes(base64: string): Uint8Array {
return tracer.startActiveSpan("bytes.encoding.base64ToBytes", (span) => {
try {
if (base64.length === 0) {
return new Uint8Array();
}
if (!BASE64_RE.test(base64)) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_BASE64"],
"Invalid base64 string: contains characters outside the standard base64 alphabet."
);
}
let binaryString: string;
try {
binaryString = atob(base64);
} catch {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_BASE64"],
"Invalid base64 string: failed to decode."
);
}
const result = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
result[i] = binaryString.charCodeAt(i);
}
return result;
} finally {
span.end();
}
});
}
/**
* Encode a `Uint8Array` to a UTF-8 string.
*
* Uses the platform `TextDecoder` for correct multi-byte character handling.
*/
export function bytesToUtf8(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.encoding.bytesToUtf8", (span) => {
try {
return new TextDecoder("utf-8").decode(bytes);
} finally {
span.end();
}
});
}
/**
* Encode a UTF-8 string to a `Uint8Array`.
*
* Uses the platform `TextEncoder` for correct multi-byte character handling.
*/
export function utf8ToBytes(str: string): Uint8Array {
return tracer.startActiveSpan("bytes.encoding.utf8ToBytes", (span) => {
try {
return new TextEncoder().encode(str);
} finally {
span.end();
}
});
}

View File

@@ -0,0 +1,9 @@
export {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "./encoding.js";
export { Base64StringSchema, HexStringSchema } from "./encoding.schemas.js";

View File

@@ -0,0 +1,89 @@
# bytes-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bytes-error.ts` defines the typed error substrate for `@suaveplan/bytes`. It registers all stable error codes under the `"BYTES"` namespace using `defineCodes` from `@suaveplan/error`, exposes the `BytesCode` union type that constrains which codes `BytesError` instances can carry, and re-exports the concrete `BytesError` class whose constructor widens visibility from `protected` to `public`.
By funnelling all package errors through a single typed class with a stable code registry, callers can narrow on `err instanceof BytesError` and then switch on `err.code` without any string fragility.
## Features
- **`BYTES_CODES`** — immutable record mapping human-readable dotted keys (e.g. `"ENCODE.INVALID_HEX"`) to stable prefixed string codes (`"BYTES.ENCODE.INVALID_HEX"`), produced by `defineCodes`.
- **`BytesCode`** — TypeScript union type of all values in `BYTES_CODES`; used as the generic parameter of `BytesError`.
- **`BytesError`** — extends `SuaveplanError<BytesCode>`; carries a stable `code`, a human-readable `message`, and an optional `cause` chain via `SuaveplanErrorOptions`.
- **Stable code registry** — codes are registered once and never change; adding a new code is a non-breaking addition; removing one is a breaking change.
## Usage: Basic Example
```ts
import { BytesError, BYTES_CODES } from "@suaveplan/bytes/errors";
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
"Hex string has odd length: 3 characters"
);
```
## Usage: Advanced Example
```ts
import { BytesError, BYTES_CODES } from "@suaveplan/bytes/errors";
function assertHex(value: string): void {
if (value.length % 2 !== 0) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
`Invalid hex string: odd length (${value.length})`,
{ cause: new RangeError("Length must be even") }
);
}
}
// Callers can narrow:
try {
assertHex("abc");
} catch (err) {
if (err instanceof BytesError) {
console.error(err.code); // "BYTES.ENCODE.INVALID_HEX"
}
}
```
## API Reference
### `BYTES_CODES`
`Record<string, BytesCode>` — maps dotted keys to prefixed code strings:
| Key | Code |
|-----|------|
| `"ENCODE.INVALID_HEX"` | `"BYTES.ENCODE.INVALID_HEX"` |
| `"ENCODE.INVALID_BASE64"` | `"BYTES.ENCODE.INVALID_BASE64"` |
| `"ENCODE.INVALID_UTF8"` | `"BYTES.ENCODE.INVALID_UTF8"` |
| `"UINT8.INVALID_INPUT"` | `"BYTES.UINT8.INVALID_INPUT"` |
| `"UINT8.FORMAT_MISMATCH"` | `"BYTES.UINT8.FORMAT_MISMATCH"` |
| `"UUID.INVALID_LENGTH"` | `"BYTES.UUID.INVALID_LENGTH"` |
| `"UUID.INVALID_FORMAT"` | `"BYTES.UUID.INVALID_FORMAT"` |
### `BytesCode`
`type BytesCode = (typeof BYTES_CODES)[keyof typeof BYTES_CODES]`
Union type of all code strings. Used as the generic argument to `SuaveplanError`.
### `BytesError`
```ts
class BytesError extends SuaveplanError<BytesCode> {
public constructor(code: BytesCode, message: string, options?: SuaveplanErrorOptions);
}
```
Typed error for all `@suaveplan/bytes` failures. The constructor widens visibility from the protected base to public.
## Implementation Notes
`defineCodes` from `@suaveplan/error` produces a frozen object whose values are the exact strings that will be serialised over the wire. The `"BYTES."` prefix guarantees no collision with codes from other packages. The `as const` assertion on the code-name array preserves tuple literal types so TypeScript can verify exhaustiveness in `switch (err.code)` statements.

View File

@@ -0,0 +1,32 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error code registry for `@suaveplan/bytes`.
*/
export const BYTES_CODES = defineCodes("BYTES", [
"ENCODE.INVALID_HEX",
"ENCODE.INVALID_BASE64",
"ENCODE.INVALID_UTF8",
"UINT8.INVALID_INPUT",
"UINT8.FORMAT_MISMATCH",
"UUID.INVALID_LENGTH",
"UUID.INVALID_FORMAT",
] as const);
export type BytesCode = (typeof BYTES_CODES)[keyof typeof BYTES_CODES];
/** Typed error for `@suaveplan/bytes`. */
export class BytesError extends SuaveplanError<BytesCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: BytesCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1 @@
export { BYTES_CODES, type BytesCode, BytesError } from "./bytes-error.js";

View File

@@ -0,0 +1,39 @@
// Errors
// Encoding
export {
Base64StringSchema,
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
HexStringSchema,
hexToBytes,
utf8ToBytes,
} from "./encoding/index.js";
export { BYTES_CODES, type BytesCode, BytesError } from "./errors/index.js";
// Types
export type { BytesInput, InputFormat, OutputFormat } from "./types/index.js";
// Uint8
export {
ConvertOptionsSchema,
concatUint8Arrays,
convertDataFormat,
detectInputFormat,
formatOutput,
InputFormatSchema,
inputToUint8Array,
OutputFormatSchema,
sliceUint8Array,
uint8ArraysEqual,
validateInputTypeFormat,
} from "./uint8/index.js";
// UUID
export {
generateUuid,
UuidStringSchema,
uint8ArrayToUuid,
uuidToUint8Array,
} from "./uuid/index.js";

View File

@@ -0,0 +1,5 @@
export type {
BytesInput,
InputFormat,
OutputFormat,
} from "./types.js";

View File

@@ -0,0 +1,78 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types.ts` defines the shared TypeScript types used across all modules in `@suaveplan/bytes`. These types form the public contract for format identifiers and accepted input values, allowing all three modules (`encoding`, `uint8`, `uuid`) to share a consistent vocabulary without any runtime code or circular dependencies.
Keeping shared types in a dedicated file — rather than scattering them across implementation files — makes the public API surface explicit and gives TypeScript enough information to provide accurate autocompletion and type narrowing at call sites.
## Features
- **`InputFormat`** — string literal union identifying the four recognised input encodings: `"hex"`, `"base64"`, `"utf8"`, `"uint8array"`.
- **`OutputFormat`** — string literal union identifying the four recognised output encodings (same set as `InputFormat`).
- **`BytesInput`** — union type accepting either a plain `string` or a `Uint8Array`; used as the input type for `inputToUint8Array`, `detectInputFormat`, and `convertDataFormat`.
## Usage: Basic Example
```ts
import type { InputFormat, OutputFormat, BytesInput } from "@suaveplan/bytes";
function toBytes(input: BytesInput, fmt: InputFormat): Uint8Array {
// TypeScript narrows input based on fmt
if (input instanceof Uint8Array) return input;
// ...
}
```
## Usage: Advanced Example
```ts
import type { OutputFormat, BytesInput } from "@suaveplan/bytes";
import { convertDataFormat } from "@suaveplan/bytes";
// A generic transform pipeline step
interface TransformStep {
input: BytesInput;
outputFormat: OutputFormat;
inputFormat?: import("@suaveplan/bytes").InputFormat;
}
function runStep(step: TransformStep): string | Uint8Array {
return convertDataFormat(step.input, step.outputFormat, step.inputFormat);
}
```
## API Reference
### `InputFormat`
```ts
type InputFormat = "hex" | "base64" | "utf8" | "uint8array";
```
Identifies the encoding of an input value passed to conversion functions. Used by `inputToUint8Array`, `detectInputFormat`, `validateInputTypeFormat`, and `convertDataFormat`.
### `OutputFormat`
```ts
type OutputFormat = "hex" | "base64" | "utf8" | "uint8array";
```
Identifies the encoding to produce from conversion functions. Used by `formatOutput` and `convertDataFormat`.
### `BytesInput`
```ts
type BytesInput = string | Uint8Array;
```
Union of the two accepted raw input types. When a `Uint8Array` is provided, no decoding step is needed. When a `string` is provided, the caller supplies an `InputFormat` or lets `detectInputFormat` infer it.
## Implementation Notes
`InputFormat` and `OutputFormat` are intentionally identical sets. They are defined as separate types rather than a single alias so that function signatures self-document the intended direction of flow (`input` vs. `output`), and so that future versions can diverge — for example, adding an `"url-base64"` output-only format — without affecting the input union.
There is no runtime code in this file. TypeScript erases all type declarations at compile time, so this module contributes zero bytes to the final bundle.

View File

@@ -0,0 +1,27 @@
/**
* Supported input encoding formats for byte conversion.
*
* - `"hex"` — lowercase hexadecimal string (e.g. `"48656c6c6f"`)
* - `"base64"` — standard base64-encoded string
* - `"utf8"` — UTF-8 text string
* - `"uint8array"` — raw `Uint8Array`
*/
export type InputFormat = "hex" | "base64" | "utf8" | "uint8array";
/**
* Supported output encoding formats for byte conversion.
*
* - `"hex"` — lowercase hexadecimal string
* - `"base64"` — standard base64-encoded string
* - `"utf8"` — UTF-8 text string
* - `"uint8array"` — raw `Uint8Array`
*/
export type OutputFormat = "hex" | "base64" | "utf8" | "uint8array";
/**
* Union of all accepted raw input types.
*
* Functions in `@suaveplan/bytes` accept either a plain `string`
* (format detected via `detectInputFormat`) or a raw `Uint8Array`.
*/
export type BytesInput = string | Uint8Array;

View File

@@ -0,0 +1,15 @@
export {
concatUint8Arrays,
convertDataFormat,
detectInputFormat,
formatOutput,
inputToUint8Array,
sliceUint8Array,
uint8ArraysEqual,
validateInputTypeFormat,
} from "./uint8.js";
export {
ConvertOptionsSchema,
InputFormatSchema,
OutputFormatSchema,
} from "./uint8.schemas.js";

View File

@@ -0,0 +1,113 @@
# uint8
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `uint8` module provides ergonomic, format-agnostic utilities for working with `Uint8Array` values: concatenation, slicing, equality testing, and format detection and conversion. It builds on the `encoding` module to offer a unified façade — `convertDataFormat` — that accepts any mix of hex, base64, UTF-8, or raw byte inputs and produces any requested output format, with automatic format detection when the caller omits an explicit hint.
The module is designed to sit at the heart of serialisation pipelines, protocol parsers, and data-transformation layers where the input format is heterogeneous or unknown at compile time.
## Features
- **`concatUint8Arrays`** — allocates a single result buffer and copies all source arrays in a single pass; accepts variadic arguments including zero arrays.
- **`sliceUint8Array`** — thin wrapper around `Uint8Array.prototype.slice` that returns a new copy (not a view) with consistent semantics.
- **`uint8ArraysEqual`** — linear-scan byte equality; safe for general-purpose comparison (not constant-time).
- **`detectInputFormat`** — heuristic: `Uint8Array` → `"uint8array"`, even-length all-hex string → `"hex"`, everything else → `"utf8"`.
- **`inputToUint8Array`** — normalise any `BytesInput` to a `Uint8Array`, calling the appropriate codec.
- **`validateInputTypeFormat`** — assert type/format compatibility and throw a typed `BytesError` on mismatch.
- **`formatOutput`** — serialise a `Uint8Array` to the desired `OutputFormat`.
- **`convertDataFormat`** — unified one-stop conversion with auto-detection, OTel instrumentation.
## Usage: Basic Example
```ts
import {
concatUint8Arrays,
uint8ArraysEqual,
convertDataFormat,
} from "@suaveplan/bytes/uint8";
// Concatenation
const header = new Uint8Array([0x01, 0x02]);
const body = new Uint8Array([0x03, 0x04, 0x05]);
const frame = concatUint8Arrays(header, body);
// → Uint8Array [1, 2, 3, 4, 5]
// Equality
uint8ArraysEqual(frame, new Uint8Array([1, 2, 3, 4, 5])); // → true
// Format conversion
const asHex = convertDataFormat(frame, "hex"); // → "0102030405"
const back = convertDataFormat(asHex, "uint8array", "hex");
// → Uint8Array [1, 2, 3, 4, 5]
```
## Usage: Advanced Example
```ts
import { convertDataFormat, detectInputFormat } from "@suaveplan/bytes/uint8";
// A pipeline that receives data in an unknown text format and normalises it
function normalise(raw: string): Uint8Array {
const fmt = detectInputFormat(raw);
console.log("detected format:", fmt); // "hex" | "utf8"
return convertDataFormat(raw, "uint8array") as Uint8Array;
}
// Produce multiple representations from one call
function multiView(input: Uint8Array) {
return {
hex: convertDataFormat(input, "hex") as string,
base64: convertDataFormat(input, "base64") as string,
utf8: convertDataFormat(input, "utf8") as string,
};
}
```
## API Reference
### `concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array`
Concatenate zero or more `Uint8Array` values into a single new `Uint8Array`. Allocates exactly one buffer sized to the sum of all input lengths.
### `sliceUint8Array(arr: Uint8Array, start: number, end?: number): Uint8Array`
Return a copy of `arr[start..end)`. If `end` is omitted, slices to the end of the array. Returns an empty `Uint8Array` when `start === end`.
### `uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean`
Return `true` iff `a.length === b.length` and every byte is identical. Not constant-time.
### `detectInputFormat(input: BytesInput): InputFormat`
Heuristically determine the format of `input`. `Uint8Array` → `"uint8array"`. Even-length all-hex string → `"hex"`. Otherwise → `"utf8"`.
### `inputToUint8Array(input: BytesInput, format?: InputFormat): Uint8Array`
Convert `input` to a `Uint8Array`. If `input` is already a `Uint8Array`, returns it unchanged. For strings, uses `format` or `detectInputFormat(input)`.
**Throws** `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` when `format === "uint8array"` but `input` is a string.
### `validateInputTypeFormat(input: BytesInput, format: InputFormat): void`
Assert that the runtime type of `input` is compatible with `format`.
**Throws** `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` on mismatch.
### `formatOutput(bytes: Uint8Array, format: OutputFormat): string | Uint8Array`
Serialise `bytes` to the given output format. Returns the same reference for `"uint8array"`.
### `convertDataFormat(input: BytesInput, outputFormat: OutputFormat, inputFormat?: InputFormat): string | Uint8Array`
Convert `input` from `inputFormat` (or auto-detected) to `outputFormat`. Wraps the operation in an OTel span named `bytes.uint8.convertDataFormat`.
## Implementation Notes
`concatUint8Arrays` pre-computes the total length in a single reduce pass and writes all sub-arrays via `Uint8Array.prototype.set`, which is optimised as a memory copy by V8 and JavaScriptCore. This is significantly faster than repeated array spreading or `Buffer.concat`-style approaches.
`detectInputFormat` uses a two-condition check: even length and a regex over the full string. The hex heuristic will misclassify a legitimate UTF-8 string that happens to be even-length and consist entirely of hex chars (e.g., `"deadbeef"`). Callers that know the format should pass it explicitly. A future version may accept a `strict` option that requires a `0x` prefix for unambiguous hex detection.
`uint8ArraysEqual` is a simple linear scan and is deliberately **not** constant-time. Do not use it to compare MACs or cryptographic secrets — use the constant-time comparison from `@suaveplan/crypto-utils` for that purpose.

View File

@@ -0,0 +1,29 @@
import z from "zod";
/**
* Zod schema for the accepted input formats.
*/
export const InputFormatSchema = z.enum([
"hex",
"base64",
"utf8",
"uint8array",
]);
/**
* Zod schema for the accepted output formats.
*/
export const OutputFormatSchema = z.enum([
"hex",
"base64",
"utf8",
"uint8array",
]);
/**
* Zod schema that validates options passed to `convertDataFormat`.
*/
export const ConvertOptionsSchema = z.object({
inputFormat: InputFormatSchema.optional(),
outputFormat: OutputFormatSchema,
});

View File

@@ -0,0 +1,238 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
concatUint8Arrays,
convertDataFormat,
detectInputFormat,
formatOutput,
inputToUint8Array,
sliceUint8Array,
uint8ArraysEqual,
validateInputTypeFormat,
} from "./uint8.js";
describe("concatUint8Arrays", () => {
it("concatenates multiple arrays", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4]);
const c = new Uint8Array([5]);
expect(concatUint8Arrays(a, b, c)).toEqual(
new Uint8Array([1, 2, 3, 4, 5])
);
});
it("handles empty arrays", { timeout: 5000 }, () => {
expect(
concatUint8Arrays(new Uint8Array(), new Uint8Array([1]))
).toEqual(new Uint8Array([1]));
});
it("handles single array", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2, 3]);
expect(concatUint8Arrays(a)).toEqual(a);
});
it("handles no arguments", { timeout: 5000 }, () => {
expect(concatUint8Arrays()).toEqual(new Uint8Array());
});
it("concatUint8Arrays associativity", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.uint8Array(),
fc.uint8Array(),
fc.uint8Array(),
(a, b, c) => {
const left = concatUint8Arrays(concatUint8Arrays(a, b), c);
const right = concatUint8Arrays(a, concatUint8Arrays(b, c));
expect(left).toEqual(right);
}
)
);
});
});
describe("sliceUint8Array", () => {
it("slices correctly", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3, 4, 5]);
expect(sliceUint8Array(arr, 1, 3)).toEqual(new Uint8Array([2, 3]));
});
it("slices from start only", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3, 4, 5]);
expect(sliceUint8Array(arr, 2)).toEqual(new Uint8Array([3, 4, 5]));
});
it("returns empty for zero-length slice", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3]);
expect(sliceUint8Array(arr, 1, 1)).toEqual(new Uint8Array());
});
});
describe("uint8ArraysEqual", () => {
it("returns true for equal arrays", { timeout: 5000 }, () => {
expect(
uint8ArraysEqual(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2, 3])
)
).toBe(true);
});
it("returns false for different arrays", { timeout: 5000 }, () => {
expect(
uint8ArraysEqual(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2, 4])
)
).toBe(false);
});
it("returns false for different lengths", { timeout: 5000 }, () => {
expect(
uint8ArraysEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))
).toBe(false);
});
it("returns true for two empty arrays", { timeout: 5000 }, () => {
expect(uint8ArraysEqual(new Uint8Array(), new Uint8Array())).toBe(true);
});
});
describe("detectInputFormat", () => {
it("detects Uint8Array", { timeout: 5000 }, () => {
expect(detectInputFormat(new Uint8Array())).toBe("uint8array");
});
it("detects hex string", { timeout: 5000 }, () => {
expect(detectInputFormat("48656c6c6f")).toBe("hex");
});
it("detects utf8 for general string", { timeout: 5000 }, () => {
expect(detectInputFormat("Hello, world!")).toBe("utf8");
});
it("detects utf8 for non-hex even string", { timeout: 5000 }, () => {
expect(detectInputFormat("not-hex-string")).toBe("utf8");
});
});
describe("inputToUint8Array", () => {
it("returns Uint8Array as-is", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3]);
expect(inputToUint8Array(arr)).toBe(arr);
});
it("converts hex string to Uint8Array", { timeout: 5000 }, () => {
expect(inputToUint8Array("48656c6c6f", "hex")).toEqual(
new Uint8Array([72, 101, 108, 108, 111])
);
});
it("converts utf8 string to Uint8Array", { timeout: 5000 }, () => {
expect(inputToUint8Array("Hello", "utf8")).toEqual(
new Uint8Array([72, 101, 108, 108, 111])
);
});
it("converts base64 string to Uint8Array", { timeout: 5000 }, () => {
expect(inputToUint8Array("SGVsbG8=", "base64")).toEqual(
new Uint8Array([72, 101, 108, 108, 111])
);
});
it("auto-detects format when not provided", { timeout: 5000 }, () => {
// "48656c6c6f" is valid hex
const result = inputToUint8Array("48656c6c6f");
expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111]));
});
it("throws when string given with uint8array format", {
timeout: 5000,
}, () => {
expect(() => inputToUint8Array("hello", "uint8array")).toThrow();
});
});
describe("validateInputTypeFormat", () => {
it("validates Uint8Array with uint8array format", { timeout: 5000 }, () => {
expect(() =>
validateInputTypeFormat(new Uint8Array(), "uint8array")
).not.toThrow();
});
it("validates string with hex format", { timeout: 5000 }, () => {
expect(() => validateInputTypeFormat("ff", "hex")).not.toThrow();
});
it("throws when Uint8Array given but string format requested", {
timeout: 5000,
}, () => {
expect(() =>
validateInputTypeFormat(new Uint8Array(), "hex")
).toThrow();
});
it("throws when string given but uint8array format requested", {
timeout: 5000,
}, () => {
expect(() => validateInputTypeFormat("hello", "uint8array")).toThrow();
});
});
describe("formatOutput", () => {
it("returns Uint8Array for uint8array format", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3]);
const result = formatOutput(arr, "uint8array");
expect(result).toBe(arr);
});
it("returns hex string for hex format", { timeout: 5000 }, () => {
expect(formatOutput(new Uint8Array([255]), "hex")).toBe("ff");
});
it("returns base64 string for base64 format", { timeout: 5000 }, () => {
expect(
formatOutput(new Uint8Array([72, 101, 108, 108, 111]), "base64")
).toBe("SGVsbG8=");
});
it("returns utf8 string for utf8 format", { timeout: 5000 }, () => {
expect(
formatOutput(new Uint8Array([72, 101, 108, 108, 111]), "utf8")
).toBe("Hello");
});
});
describe("convertDataFormat", () => {
it("converts hex to utf8", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "utf8", "hex")).toBe("Hello");
});
it("converts utf8 to hex", { timeout: 5000 }, () => {
expect(convertDataFormat("Hello", "hex", "utf8")).toBe("48656c6c6f");
});
it("converts Uint8Array to hex", { timeout: 5000 }, () => {
expect(
convertDataFormat(new Uint8Array([72, 101, 108, 108, 111]), "hex")
).toBe("48656c6c6f");
});
it("converts hex to base64", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "base64", "hex")).toBe(
"SGVsbG8="
);
});
it("converts Uint8Array to uint8array (identity)", {
timeout: 5000,
}, () => {
const arr = new Uint8Array([1, 2, 3]);
expect(convertDataFormat(arr, "uint8array")).toBe(arr);
});
it("auto-detects input format for hex strings", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "utf8")).toBe("Hello");
});
});

View File

@@ -0,0 +1,195 @@
import { trace } from "@opentelemetry/api";
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "../encoding/encoding.js";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
import type { BytesInput, InputFormat, OutputFormat } from "../types/types.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** Regex for even-length all-hex strings. */
const HEX_PATTERN = /^[0-9a-fA-F]+$/;
/**
* Concatenate any number of `Uint8Array` instances into a new `Uint8Array`.
*
* Returns an empty `Uint8Array` when called with no arguments.
*/
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Return a slice of `arr` from `start` (inclusive) to `end` (exclusive).
*
* Semantics mirror `Array.prototype.slice` — negative indices are not
* supported; pass only non-negative integers.
*/
export function sliceUint8Array(
arr: Uint8Array,
start: number,
end?: number
): Uint8Array {
return arr.slice(start, end);
}
/**
* Return `true` when `a` and `b` have the same length and identical byte
* values at every position.
*
* This comparison is **not** constant-time — do not use it for
* cryptographic MAC verification. Use `@suaveplan/crypto-utils` for
* constant-time equality.
*/
export function uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Detect the most likely `InputFormat` of `input`.
*
* - `Uint8Array` → `"uint8array"`
* - Even-length string composed entirely of hex characters → `"hex"`
* - Everything else → `"utf8"`
*
* This heuristic is intentionally conservative: a string that looks like hex
* (even length, all hex chars) is classified as hex. If the caller knows the
* format, pass it explicitly to `inputToUint8Array`.
*/
export function detectInputFormat(input: BytesInput): InputFormat {
if (input instanceof Uint8Array) {
return "uint8array";
}
if (input.length > 0 && input.length % 2 === 0 && HEX_PATTERN.test(input)) {
return "hex";
}
return "utf8";
}
/**
* Assert that `input`'s runtime type is compatible with `format`.
*
* - `"uint8array"` requires `input` to be a `Uint8Array`.
* - `"hex"`, `"base64"`, `"utf8"` require `input` to be a `string`.
*
* Throws `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` on mismatch.
*/
export function validateInputTypeFormat(
input: BytesInput,
format: InputFormat
): void {
const isUint8 = input instanceof Uint8Array;
if (format === "uint8array" && !isUint8) {
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "uint8array" requires a Uint8Array input, but received a string.`
);
}
if (format !== "uint8array" && isUint8) {
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "${format}" requires a string input, but received a Uint8Array.`
);
}
}
/**
* Convert `input` to a `Uint8Array`.
*
* When `format` is omitted and `input` is a string, `detectInputFormat` is
* used to infer the encoding.
*
* Throws `BytesError(BYTES.UINT8.INVALID_INPUT)` when `input` is neither a
* `string` nor a `Uint8Array`.
*/
export function inputToUint8Array(
input: BytesInput,
format?: InputFormat
): Uint8Array {
if (input instanceof Uint8Array) {
return input;
}
const resolvedFormat: InputFormat = format ?? detectInputFormat(input);
switch (resolvedFormat) {
case "hex":
return hexToBytes(input);
case "base64":
return base64ToBytes(input);
case "utf8":
return utf8ToBytes(input);
case "uint8array":
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "uint8array" requires a Uint8Array, but received a string.`
);
}
}
/**
* Serialise `bytes` to the requested `OutputFormat`.
*
* - `"uint8array"` returns the same reference unchanged.
* - `"hex"` returns a lowercase hex string.
* - `"base64"` returns a standard base64 string.
* - `"utf8"` returns a UTF-8 decoded string.
*/
export function formatOutput(
bytes: Uint8Array,
format: OutputFormat
): string | Uint8Array {
switch (format) {
case "uint8array":
return bytes;
case "hex":
return bytesToHex(bytes);
case "base64":
return bytesToBase64(bytes);
case "utf8":
return bytesToUtf8(bytes);
}
}
/**
* Convert `input` from `inputFormat` (or auto-detected) to `outputFormat`.
*
* This is the primary façade for cross-format conversion:
*
* ```ts
* convertDataFormat("48656c6c6f", "utf8", "hex") // → "Hello"
* convertDataFormat("Hello", "hex", "utf8") // → "48656c6c6f"
* convertDataFormat(bytes, "base64") // → "SGVsbG8="
* ```
*
* When `inputFormat` is omitted and `input` is a string, `detectInputFormat`
* is called automatically.
*/
export function convertDataFormat(
input: BytesInput,
outputFormat: OutputFormat,
inputFormat?: InputFormat
): string | Uint8Array {
return tracer.startActiveSpan("bytes.uint8.convertDataFormat", (span) => {
try {
const bytes = inputToUint8Array(input, inputFormat);
return formatOutput(bytes, outputFormat);
} finally {
span.end();
}
});
}

View File

@@ -0,0 +1,2 @@
export { generateUuid, uint8ArrayToUuid, uuidToUint8Array } from "./uuid.js";
export { UuidStringSchema } from "./uuid.schemas.js";

View File

@@ -0,0 +1,97 @@
# uuid
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `uuid` module provides three focused primitives for working with Universally Unique Identifiers in `@suaveplan/bytes`: generating cryptographically random UUIDv4 strings, converting canonical UUID strings to their compact 16-byte binary representation, and converting a 16-byte `Uint8Array` back to the canonical hyphen-separated string. These operations are essential for any system that stores entity identifiers as binary blobs in databases, network protocols, or binary serialisation formats while presenting them as human-readable strings at the API boundary.
All three functions validate their inputs strictly and throw typed `BytesError` values on malformed data, making them safe to use directly in request handlers and protocol parsers.
## Features
- **`generateUuid`** — generates a UUIDv4 string using the platform `crypto.randomUUID()` — no third-party uuid library, no custom random-number manipulation.
- **`uuidToUint8Array`** — parses a canonical UUID string into exactly 16 bytes; validates format with a regex before any byte parsing.
- **`uint8ArrayToUuid`** — formats a 16-byte `Uint8Array` as a canonical lowercase UUID string; validates length before formatting.
- **OTel tracing** — every function is wrapped in a named active span under the `@suaveplan/bytes` tracer.
- **Zod schema** — `UuidStringSchema` validates the canonical UUID format and is composable with any Zod object schema.
## Usage: Basic Example
```ts
import { generateUuid, uuidToUint8Array, uint8ArrayToUuid } from "@suaveplan/bytes/uuid";
// Generate a fresh UUIDv4
const id = generateUuid();
// → "550e8400-e29b-41d4-a716-446655440000" (random each call)
// Convert to binary for compact storage
const bytes = uuidToUint8Array(id);
// → Uint8Array of length 16
// Restore the canonical string
const restored = uint8ArrayToUuid(bytes);
// → same as `id`
```
## Usage: Advanced Example
```ts
import { uuidToUint8Array, uint8ArrayToUuid, BytesError } from "@suaveplan/bytes";
import { UuidStringSchema } from "@suaveplan/bytes/uuid";
import z from "zod";
// Validate and convert an incoming UUID header to binary for DB storage
const RequestSchema = z.object({
entityId: UuidStringSchema,
});
function handleRequest(raw: unknown): Uint8Array {
const { entityId } = RequestSchema.parse(raw);
try {
return uuidToUint8Array(entityId);
} catch (err) {
if (err instanceof BytesError) {
// err.code === "BYTES.UUID.INVALID_FORMAT"
throw new Error(`Malformed entity ID: ${err.message}`);
}
throw err;
}
}
// Convert back to string for API responses
function toApiResponse(raw: Uint8Array): string {
return uint8ArrayToUuid(raw);
}
```
## API Reference
### `generateUuid(): string`
Generate a UUID version 4 string using `crypto.randomUUID()`. Output is always lowercase and matches the pattern `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/`.
### `uuidToUint8Array(uuid: string): Uint8Array`
Parse a canonical UUID string (upper- or lowercase) into a 16-byte `Uint8Array`. Strips the four hyphens, then reads each pair of hex digits as a byte.
**Throws** `BytesError(BYTES.UUID.INVALID_FORMAT)` when `uuid` does not match the canonical pattern `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
### `uint8ArrayToUuid(bytes: Uint8Array): string`
Format a 16-byte `Uint8Array` as a canonical lowercase UUID string in the form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
**Throws** `BytesError(BYTES.UUID.INVALID_LENGTH)` when `bytes.length !== 16`.
### `UuidStringSchema` (from `uuid.schemas.ts`)
Zod schema that validates the canonical UUID format. Accepts both upper- and lowercase hex digits. Does not verify UUID version or variant bits.
## Implementation Notes
`generateUuid` delegates entirely to the platform `crypto.randomUUID()` (Node 14.17+, Bun, all modern browsers), ensuring the UUID is generated from a cryptographically secure source of randomness with no custom bit-twiddling. A polyfill that sets `globalThis.crypto = require("node:crypto").webcrypto` is included in the package test setup so that Node `forks`-pool Vitest runs have access to the `crypto` global.
`uuidToUint8Array` validates the input with a compiled regex before any byte parsing. This rejects inputs that are structurally wrong (wrong group lengths, non-hex chars, missing hyphens) with a single check rather than per-byte error detection.
`uint8ArrayToUuid` materialises a hex string from the byte array, then splices in the four hyphens at the standard offsets (8, 12, 16, 20 hex chars). Output is always lowercase because `Number.prototype.toString(16)` produces lowercase hex on all platforms.

View File

@@ -0,0 +1,15 @@
import z from "zod";
/**
* Zod schema for a canonical UUID string.
*
* Validates the format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` where each `x`
* is a hexadecimal digit (0–9, a–f, A–F). Does not verify the version or
* variant bits — use `generateUuid` to obtain a guaranteed UUIDv4.
*/
export const UuidStringSchema = z
.string()
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
"Must be a valid UUID string in canonical form"
);

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateUuid, uint8ArrayToUuid, uuidToUint8Array } from "./uuid.js";
describe("uuid round-trip", () => {
it("round-trips a known UUID", { timeout: 5000 }, () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
it("uuidToUint8Array produces 16 bytes", { timeout: 5000 }, () => {
expect(
uuidToUint8Array("550e8400-e29b-41d4-a716-446655440000")
).toHaveLength(16);
});
it("throws on invalid UUID", { timeout: 5000 }, () => {
expect(() => uuidToUint8Array("not-a-uuid")).toThrow();
});
it("throws on UUID with wrong structure", { timeout: 5000 }, () => {
expect(() =>
uuidToUint8Array("550e8400e29b41d4a716446655440000")
).toThrow();
});
it("uint8ArrayToUuid throws on non-16-byte array", {
timeout: 5000,
}, () => {
expect(() => uint8ArrayToUuid(new Uint8Array(15))).toThrow();
});
it("uint8ArrayToUuid throws on empty array", { timeout: 5000 }, () => {
expect(() => uint8ArrayToUuid(new Uint8Array(0))).toThrow();
});
it("round-trips all-zeros UUID", { timeout: 5000 }, () => {
const uuid = "00000000-0000-0000-0000-000000000000";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
it("round-trips all-fs UUID", { timeout: 5000 }, () => {
const uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
});
describe("generateUuid", () => {
it("generates a valid UUID v4 string", { timeout: 5000 }, () => {
const uuid = generateUuid();
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
it("generates unique values", { timeout: 5000 }, () => {
expect(generateUuid()).not.toBe(generateUuid());
});
it("output is lowercase", { timeout: 5000 }, () => {
const uuid = generateUuid();
expect(uuid).toBe(uuid.toLowerCase());
});
it("generated UUID can round-trip through uint8Array", {
timeout: 5000,
}, () => {
const uuid = generateUuid();
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
});

View File

@@ -0,0 +1,87 @@
import { trace } from "@opentelemetry/api";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** UUID canonical format: 8-4-4-4-12 lowercase hex groups separated by hyphens. */
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Generate a UUID v4 string using the platform `crypto.randomUUID()`.
*
* The output is always lowercase and conforms to the canonical
* `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` format.
*/
export function generateUuid(): string {
return tracer.startActiveSpan("bytes.uuid.generateUuid", (span) => {
try {
return crypto.randomUUID();
} finally {
span.end();
}
});
}
/**
* Parse a canonical UUID string into a 16-byte `Uint8Array`.
*
* Accepts both upper- and lowercase hex. Dashes are stripped before parsing.
*
* @throws `BytesError(BYTES.UUID.INVALID_FORMAT)` when `uuid` does not
* match the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` pattern.
*/
export function uuidToUint8Array(uuid: string): Uint8Array {
return tracer.startActiveSpan("bytes.uuid.uuidToUint8Array", (span) => {
try {
if (!UUID_RE.test(uuid)) {
throw new BytesError(
BYTES_CODES["UUID.INVALID_FORMAT"],
`Invalid UUID format: "${uuid}". Expected canonical form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.`
);
}
const hex = uuid.replace(/-/g, "");
const result = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
result[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return result;
} finally {
span.end();
}
});
}
/**
* Format a 16-byte `Uint8Array` as a canonical lowercase UUID string.
*
* @throws `BytesError(BYTES.UUID.INVALID_LENGTH)` when `bytes` is not
* exactly 16 bytes long.
*/
export function uint8ArrayToUuid(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.uuid.uint8ArrayToUuid", (span) => {
try {
if (bytes.length !== 16) {
throw new BytesError(
BYTES_CODES["UUID.INVALID_LENGTH"],
`Invalid UUID byte array length: ${bytes.length}. Expected exactly 16 bytes.`
);
}
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join("-");
} finally {
span.end();
}
});
}

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest forks runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bytes",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
"tests/setup/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});

View File

@@ -0,0 +1,92 @@
# @suaveplan/clipboard - Enterprise Clipboard API abstraction: typed errors, OTel metrics, async queue serializer, secure-context guard, image/blob copy + read, paste-event read fallback, permission change subscription, focus-aware retry, sanitizer hook. Browser-tier, zero @suaveplan/* runtime deps beyond error + opentelemetry + types.
Enterprise Clipboard API abstraction for browser-tier consumers. Wraps the
async Clipboard API with a textarea + `execCommand` fallback, a paste-event
read fallback, image / blob copy + read via `ClipboardItem`, FIFO
serialization, secure-context guard, permission change subscription,
focus-aware retry, optional HTML sanitizer, structured errors via
`@suaveplan/error`, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/errors.md) — Stable error code registry, per-code metadata, and the concrete `ClipboardError` class for the `@suaveplan/clipboard` p…
- [errors](./src/errors/errors.md) — Stable error code registry, per-code metadata, and the concrete `ClipboardError` class for the `@suaveplan/clipboard` p…
- [fallback](./src/fallback/fallback.md) — Legacy clipboard fallback paths used when `navigator.clipboard` is unavailable or rejects. Provides a hidden-textarea +…
- [fallback](./src/fallback/fallback.md) — Legacy clipboard fallback paths used when `navigator.clipboard` is unavailable or rejects. Provides a hidden-textarea +…
- [manager](./src/manager/manager.md) — `ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`. It composes the navigator + window seams, the…
- [helpers](./src/manager/helpers.md) — Pure helpers shared between `manager.ts` and `operations.ts`. Keeps both files under the line budget by housing the nav…
- [manager](./src/manager/manager.md) — `ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`. It composes the navigator + window seams, the…
- [operations](./src/manager/operations.md) — Pure operation functions invoked by `ClipboardManager`. Each function takes an `OperationContext` (navigator, window, o…
- [observability](./src/observability/observability.md) — OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires four counters and one histogram on a single `Met…
- [observability](./src/observability/observability.md) — OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires four counters and one histogram on a single `Met…
- [permissions](./src/permissions/permissions.md) — Probe and subscribe to clipboard permission state via the browser's Permissions API. `checkPermissions` returns a snaps…
- [permissions](./src/permissions/permissions.md) — Probe and subscribe to clipboard permission state via the browser's Permissions API. `checkPermissions` returns a snaps…
- [queue](./src/queue/queue.md) — `AsyncQueue` is a minimal FIFO asynchronous queue that serializes operations on a single tail promise. The `ClipboardMa…
- [queue](./src/queue/queue.md) — `AsyncQueue` is a minimal FIFO asynchronous queue that serializes operations on a single tail promise. The `ClipboardMa…
- [sanitizer](./src/sanitizer/sanitizer.md) — Sanitizer hooks applied to inbound `text/html` clipboard payloads. The package ships an identity sanitizer (pass-throug…
- [sanitizer](./src/sanitizer/sanitizer.md) — Sanitizer hooks applied to inbound `text/html` clipboard payloads. The package ships an identity sanitizer (pass-throug…
- [security](./src/security/security.md) — Browser-side security helpers that guard clipboard operations against insecure contexts and orchestrate the focus-aware…
- [security](./src/security/security.md) — Browser-side security helpers that guard clipboard operations against insecure contexts and orchestrate the focus-aware…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/clipboard
```
## Quick start
```ts
import { ClipboardManager } from "@suaveplan/clipboard";
const clipboard = new ClipboardManager({
useFallback: true,
requireSecureContext: false,
serialize: true,
});
const result = await clipboard.copyText("hello");
if (!result.success) {
console.error("copy failed:", result.code, result.error);
}
```
## Modules
- [`manager/manager.md`](./src/manager/manager.md) — `ClipboardManager`, the orchestrator.
- [`manager/helpers.md`](./src/manager/helpers.md) — internal helpers used by the manager.
- [`manager/operations.md`](./src/manager/operations.md) — pure operation functions.
- [`errors/errors.md`](./src/errors/errors.md) — `CLIPBOARD_CODES`, `ClipboardError`, metadata.
- [`fallback/fallback.md`](./src/fallback/fallback.md) — textarea + paste-event fallback paths.
- [`observability/observability.md`](./src/observability/observability.md) — OpenTelemetry counters + histogram.
- [`permissions/permissions.md`](./src/permissions/permissions.md) — permission probe + change subscription.
- [`queue/queue.md`](./src/queue/queue.md) — `AsyncQueue` FIFO serializer.
- [`sanitizer/sanitizer.md`](./src/sanitizer/sanitizer.md) — sanitizer interface + helpers.
- [`security/security.md`](./src/security/security.md) — secure-context guards + focus utilities.
## Features
- Copy plain text, HTML, images, and arbitrary MIME blobs.
- Read plain text and rich content with optional HTML sanitization.
- Paste-event fallback when `navigator.clipboard.readText` is unavailable.
- Permission probe + live subscription (`onPermissionChange`).
- Async FIFO queue so concurrent calls execute in arrival order.
- Secure-context guard with typed error code (`CLIPBOARD.INSECURE_CONTEXT`).
- Focus-aware retry on `NotAllowedError: Document is not focused`.
- Eight stable error codes under the `CLIPBOARD.*` namespace.
- Five OpenTelemetry instruments wired into every public method.
- SSR-safe — graceful degradation when `document` / `navigator` / `window` are undefined.
## Error codes
`CLIPBOARD.API_UNAVAILABLE`, `CLIPBOARD.PERMISSION_DENIED`,
`CLIPBOARD.FALLBACK_FAILED`, `CLIPBOARD.INSECURE_CONTEXT`,
`CLIPBOARD.NO_CONTENT`, `CLIPBOARD.READ_FAILED`,
`CLIPBOARD.DOCUMENT_UNAVAILABLE`, `CLIPBOARD.QUEUE_DRAINED`.
## License
MIT

View File

@@ -0,0 +1,110 @@
{
"name": "@suaveplan/clipboard",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Enterprise Clipboard API abstraction: typed errors, OTel metrics, async queue serializer, secure-context guard, image/blob copy + read, paste-event read fallback, permission change subscription, focus-aware retry, sanitizer hook. Browser-tier, zero @suaveplan/* runtime deps beyond error + opentelemetry + types.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./manager/manager": {
"import": {
"types": "./dist/manager/manager.d.ts",
"default": "./dist/manager/manager.js"
}
},
"./fallback/fallback": {
"import": {
"types": "./dist/fallback/fallback.d.ts",
"default": "./dist/fallback/fallback.js"
}
},
"./permissions/permissions": {
"import": {
"types": "./dist/permissions/permissions.d.ts",
"default": "./dist/permissions/permissions.js"
}
},
"./queue/queue": {
"import": {
"types": "./dist/queue/queue.d.ts",
"default": "./dist/queue/queue.js"
}
},
"./observability/observability": {
"import": {
"types": "./dist/observability/observability.d.ts",
"default": "./dist/observability/observability.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./security/security": {
"import": {
"types": "./dist/security/security.d.ts",
"default": "./dist/security/security.js"
}
},
"./sanitizer/sanitizer": {
"import": {
"types": "./dist/sanitizer/sanitizer.d.ts",
"default": "./dist/sanitizer/sanitizer.js"
}
},
"./types": {
"import": {
"types": "./dist/types/clipboard.types.d.ts",
"default": "./dist/types/clipboard.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-dom": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,126 @@
# `errors/errors.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error code registry, per-code metadata, and the concrete
`ClipboardError` class for the `@suaveplan/clipboard` package. Every error
path inside the manager, fallback, permissions, and security modules
surfaces one of these codes so consumers can match programmatically
without inspecting message strings. Codes are namespaced under
`CLIPBOARD.*` and registered via the shared `defineCodes` helper from
`@suaveplan/error` so the runtime values, the literal union type, and the
metadata table stay in lock-step.
## Features
- Eight stable codes: `API_UNAVAILABLE`, `PERMISSION_DENIED`,
`FALLBACK_FAILED`, `INSECURE_CONTEXT`, `NO_CONTENT`, `READ_FAILED`,
`DOCUMENT_UNAVAILABLE`, `QUEUE_DRAINED`.
- Per-code metadata: `httpStatus`, `retryable`, `severity`, `description`.
- `ClipboardError extends SuaveplanError<ClipboardErrorCode>` — preserves
cause chains, supports OTel span events, and integrates with the
project's typed-error contract.
- `asClipboardError(code, cause, fallbackMessage)` lifts unknown thrown
values into the typed hierarchy at every catch site.
## Usage: Basic
```ts
import {
CLIPBOARD_CODES,
ClipboardError,
} from "@suaveplan/clipboard";
try {
// ...
} catch (cause) {
throw new ClipboardError(
CLIPBOARD_CODES.READ_FAILED,
"Clipboard read rejected",
{ cause: cause instanceof Error ? cause : new Error(String(cause)) },
);
}
```
## Usage: Advanced
```ts
import {
asClipboardError,
CLIPBOARD_CODES,
CLIPBOARD_ERROR_METADATA,
} from "@suaveplan/clipboard";
function decide(err: unknown): { retry: boolean; status: number } {
if (
err instanceof Error &&
"code" in err &&
typeof (err as { code?: string }).code === "string" &&
(err as { code: string }).code.startsWith("CLIPBOARD.")
) {
const meta =
CLIPBOARD_ERROR_METADATA[
(err as { code: keyof typeof CLIPBOARD_ERROR_METADATA })
.code
];
return { retry: meta.retryable, status: meta.httpStatus };
}
const lifted = asClipboardError(
CLIPBOARD_CODES.READ_FAILED,
err,
"Unknown clipboard failure.",
);
return {
retry: lifted.metadata.retryable,
status: lifted.metadata.httpStatus,
};
}
```
## API Reference
### `CLIPBOARD_CODES`
`Readonly<Record<<key>, "CLIPBOARD.<key>">>` produced by `defineCodes`.
The runtime values are namespaced strings; the type infers a literal
union for exhaustive matching.
### `CLIPBOARD_ERROR_METADATA`
`Readonly<Record<ClipboardErrorCode, ClipboardCodeMetadata>>` — frozen map
from each code to its `httpStatus`, `retryable`, `severity`, and
`description` fields.
### `ClipboardCodeMetadata`
Interface with `httpStatus: number`, `severity: "info" | "warning" | "error"`,
`retryable: boolean`, `description: string`.
### `ClipboardError`
Concrete subclass of `SuaveplanError<ClipboardErrorCode>`. Constructor
signature: `new ClipboardError(code, message, options?)`. Exposes a
`.metadata` getter that returns the row from
`CLIPBOARD_ERROR_METADATA`.
### `asClipboardError(code, cause, fallbackMessage)`
Lifts an arbitrary `unknown` thrown value into a `ClipboardError`,
preserving the cause chain. Uses `cause.message` when available and
`fallbackMessage` otherwise. Always returns a `ClipboardError` with the
supplied code.
## Implementation Notes
The package version of `defineCodes` is the canonical helper from
`@suaveplan/error`; every package uses the same pattern so cross-
cutting tooling (registry validators, retention checks, OpenSpec
conformance) can introspect every namespace identically. Codes are
intentionally namespaced strings rather than enums so they remain JSON-
serialisable and stable across versions. The metadata table is `Object.freeze`-d
so mutation is a typecheck error and a runtime error; reviewers can
diff additions in pull requests without worrying about silent rewrites.

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
asClipboardError,
CLIPBOARD_CODES,
CLIPBOARD_ERROR_METADATA,
ClipboardError,
} from "./errors.js";
describe("CLIPBOARD_CODES", () => {
it("CLIPBOARD_CODES has all eight stable codes", { timeout: 5000 }, () => {
expect(Object.keys(CLIPBOARD_CODES).sort()).toEqual(
[
"API_UNAVAILABLE",
"DOCUMENT_UNAVAILABLE",
"FALLBACK_FAILED",
"INSECURE_CONTEXT",
"NO_CONTENT",
"PERMISSION_DENIED",
"QUEUE_DRAINED",
"READ_FAILED",
].sort()
);
for (const value of Object.values(CLIPBOARD_CODES)) {
expect(value.startsWith("CLIPBOARD.")).toBe(true);
}
});
it("CLIPBOARD_ERROR_METADATA reports correct shape per code", {
timeout: 5000,
}, () => {
expect(
CLIPBOARD_ERROR_METADATA[CLIPBOARD_CODES.INSECURE_CONTEXT]
).toEqual({
httpStatus: 400,
severity: "error",
retryable: false,
description: expect.any(String),
});
for (const code of Object.values(CLIPBOARD_CODES)) {
const meta = CLIPBOARD_ERROR_METADATA[code];
expect(typeof meta.httpStatus).toBe("number");
expect(meta.description.length).toBeGreaterThan(0);
}
});
});
describe("ClipboardError", () => {
it("ClipboardError preserves code and cause", { timeout: 5000 }, () => {
const cause = new Error("native");
const err = new ClipboardError(
CLIPBOARD_CODES.API_UNAVAILABLE,
"no api",
{ cause }
);
expect(err).toBeInstanceOf(ClipboardError);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe(CLIPBOARD_CODES.API_UNAVAILABLE);
expect(err.message).toBe("no api");
expect(err.cause).toBe(cause);
expect(err.name).toBe("ClipboardError");
expect(err.metadata.httpStatus).toBe(501);
});
});
describe("asClipboardError", () => {
it("wraps Error cause and preserves its message", { timeout: 5000 }, () => {
const cause = new Error("specific");
const err = asClipboardError(
CLIPBOARD_CODES.READ_FAILED,
cause,
"fallback"
);
expect(err.code).toBe(CLIPBOARD_CODES.READ_FAILED);
expect(err.message).toBe("specific");
expect(err.cause).toBe(cause);
});
it("uses fallback message when cause has no message", {
timeout: 5000,
}, () => {
const err = asClipboardError(
CLIPBOARD_CODES.FALLBACK_FAILED,
"string-thrown",
"fallback-msg"
);
expect(err.message).toBe("fallback-msg");
expect(err.cause).toBeInstanceOf(Error);
expect((err.cause as Error).message).toBe("string-thrown");
});
it("uses fallback message when Error message is empty", {
timeout: 5000,
}, () => {
const err = asClipboardError(
CLIPBOARD_CODES.FALLBACK_FAILED,
new Error(""),
"fallback-msg"
);
expect(err.message).toBe("fallback-msg");
});
});

View File

@@ -0,0 +1,133 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/clipboard`. Every error path
* the package can take surfaces one of these codes so consumers (loggers,
* retry policies, UI surfaces, telemetry pipelines) can classify failures
* programmatically without parsing message strings.
*/
export const CLIPBOARD_CODES = defineCodes("CLIPBOARD", [
"API_UNAVAILABLE",
"PERMISSION_DENIED",
"FALLBACK_FAILED",
"INSECURE_CONTEXT",
"NO_CONTENT",
"READ_FAILED",
"DOCUMENT_UNAVAILABLE",
"QUEUE_DRAINED",
] as const);
/** Union of every error code emitted by `@suaveplan/clipboard`. */
export type ClipboardErrorCode =
(typeof CLIPBOARD_CODES)[keyof typeof CLIPBOARD_CODES];
/** Per-code metadata for HTTP / retry / severity classification. */
export interface ClipboardCodeMetadata {
readonly httpStatus: number;
readonly severity: "info" | "warning" | "error";
readonly retryable: boolean;
readonly description: string;
}
/** Mapping of every {@link ClipboardErrorCode} to its metadata. */
export const CLIPBOARD_ERROR_METADATA: Readonly<
Record<ClipboardErrorCode, ClipboardCodeMetadata>
> = Object.freeze({
[CLIPBOARD_CODES.API_UNAVAILABLE]: {
httpStatus: 501,
severity: "warning",
retryable: false,
description:
"navigator.clipboard is undefined; the host environment does not expose the async Clipboard API.",
},
[CLIPBOARD_CODES.PERMISSION_DENIED]: {
httpStatus: 403,
severity: "warning",
retryable: false,
description:
"The Permissions API reported state=denied for clipboard-read or clipboard-write.",
},
[CLIPBOARD_CODES.FALLBACK_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: false,
description:
"document.execCommand('copy') returned false or threw during the textarea fallback.",
},
[CLIPBOARD_CODES.INSECURE_CONTEXT]: {
httpStatus: 400,
severity: "error",
retryable: false,
description:
"window.isSecureContext is false; clipboard operations require HTTPS or localhost when requireSecureContext is enabled.",
},
[CLIPBOARD_CODES.NO_CONTENT]: {
httpStatus: 400,
severity: "warning",
retryable: false,
description:
"copyRichContent was called with empty ClipboardData (no text, html, image, or blobs).",
},
[CLIPBOARD_CODES.READ_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: true,
description:
"Clipboard read failed; the underlying API rejected or the paste-event fallback timed out.",
},
[CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE]: {
httpStatus: 501,
severity: "error",
retryable: false,
description:
"document is undefined; the fallback path cannot run outside a DOM environment.",
},
[CLIPBOARD_CODES.QUEUE_DRAINED]: {
httpStatus: 503,
severity: "warning",
retryable: true,
description:
"ClipboardManager was disposed while a queued operation was still pending.",
},
});
/**
* Base error class for every failure raised by `@suaveplan/clipboard`.
* Extends {@link SuaveplanError} so consumers receive cause-chain traversal,
* Zod-validated wire format, redaction hooks, and the OTel span-event
* adapter for free.
*/
export class ClipboardError extends SuaveplanError<ClipboardErrorCode> {
constructor(
code: ClipboardErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "ClipboardError";
}
/** Metadata for this error's code. */
get metadata(): ClipboardCodeMetadata {
return CLIPBOARD_ERROR_METADATA[this.code];
}
}
/**
* Lift an unknown thrown value into a {@link ClipboardError}. Used at every
* `catch` site so call sites can rely on the typed error contract.
*/
export function asClipboardError(
code: ClipboardErrorCode,
cause: unknown,
fallbackMessage: string
): ClipboardError {
const message =
cause instanceof Error && cause.message
? cause.message
: fallbackMessage;
const causeError =
cause instanceof Error ? cause : new Error(String(cause));
return new ClipboardError(code, message, { cause: causeError });
}

View File

@@ -0,0 +1,8 @@
export {
asClipboardError,
CLIPBOARD_CODES,
CLIPBOARD_ERROR_METADATA,
type ClipboardCodeMetadata,
ClipboardError,
type ClipboardErrorCode,
} from "./errors.js";

View File

@@ -0,0 +1,94 @@
# `fallback/fallback.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Legacy clipboard fallback paths used when `navigator.clipboard` is
unavailable or rejects. Provides a hidden-textarea + `execCommand("copy")`
write path and a paste-event-based read path. Both return the same
`ClipboardResult` shape as the API path so the manager's call sites do
not branch on which mechanism actually ran. The paste-event fallback is
purpose-built for environments — older Safari, embedded WebViews, and
permission-denied contexts — where `navigator.clipboard.readText`
rejects but the user can paste manually.
## Features
- `copyTextFallback(text)` — synchronous textarea + `execCommand` copy.
- `readTextFallback(timeoutMs)` — focus + paste-event capture with a
configurable timeout.
- `dispatchSyntheticPaste(text)` — test helper that fires a synthetic
`paste` event on the most recent hidden host.
- SSR-safe — both copy and read paths return
`CLIPBOARD.DOCUMENT_UNAVAILABLE` when `document` is undefined.
- Off-screen, non-interactive host element so users never see the
textarea visibly steal focus.
## Usage: Basic
```ts
import { copyTextFallback } from "@suaveplan/clipboard";
const result = copyTextFallback("hello");
if (!result.success) {
console.warn("legacy copy failed:", result.code);
}
```
## Usage: Advanced
```ts
import {
readTextFallback,
dispatchSyntheticPaste,
} from "@suaveplan/clipboard";
async function readClipboardWithPasteFallback(): Promise<string | undefined> {
const result = await readTextFallback(2000);
return result.success ? result.data : undefined;
}
// Inside a test harness:
const promise = readTextFallback(500);
queueMicrotask(() => dispatchSyntheticPaste("from-test"));
const result = await promise;
```
## API Reference
### `copyTextFallback(text: string): ClipboardResult<string>`
Stages a hidden `<textarea>` carrying `text`, selects it, runs
`document.execCommand("copy")`, and removes the textarea on cleanup.
Returns `{ success: true, data, method: "fallback" }` on success and a
structured failure with `code: CLIPBOARD.FALLBACK_FAILED` (or
`CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser) otherwise.
### `readTextFallback(timeoutMs: number): Promise<ClipboardResult<string>>`
Creates a hidden textarea, focuses it, listens for the first `paste`
event, and resolves with the `text/plain` payload carried by the event.
Resolves with `code: CLIPBOARD.READ_FAILED` if the timeout elapses
first and `code: CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser.
### `dispatchSyntheticPaste(text: string): boolean`
Test helper. Locates the most recent hidden host (`textarea[aria-hidden="true"]`)
and dispatches a `paste` ClipboardEvent carrying `text` as `text/plain`.
Returns `true` when a host was found, `false` otherwise.
## Implementation Notes
The hidden textarea is positioned at `-9999px` and given `opacity: 0`
plus `aria-hidden="true"` so assistive technologies skip it. The cleanup
runs in a `finally` block — even if `execCommand` throws, the textarea is
removed. The read fallback tracks a `settled` flag so a duplicate or
post-timeout `paste` event cannot resolve the promise twice. The paste
event may carry a null `clipboardData` on some browsers; the module
falls back to an empty string in that case rather than rejecting, since
"clipboard contained nothing" is a valid outcome. The synthetic-paste
helper is exported to keep tests deterministic — production code never
calls it.

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES } from "../errors/errors.js";
import {
copyTextFallback,
dispatchSyntheticPaste,
readTextFallback,
} from "./fallback.js";
function withExecCommand<T>(
impl: (() => boolean) | (() => never),
fn: () => T
): T {
const original = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
impl as () => boolean;
try {
return fn();
} finally {
document.execCommand = original;
}
}
describe("copyTextFallback", () => {
it("copies text when execCommand returns true", { timeout: 5000 }, () => {
const result = withExecCommand(
() => true,
() => copyTextFallback("hello")
);
expect(result).toEqual({
success: true,
data: "hello",
method: "fallback",
});
});
it("copyTextFallback reports FALLBACK_FAILED when execCommand returns false", {
timeout: 5000,
}, () => {
const result = withExecCommand(
() => false,
() => copyTextFallback("x")
);
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
expect(result.method).toBe("fallback");
});
it("copyTextFallback reports FALLBACK_FAILED when execCommand throws", {
timeout: 5000,
}, () => {
const result = withExecCommand(
() => {
throw new Error("denied");
},
() => copyTextFallback("x")
);
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
expect(result.error).toBe("denied");
});
it("copyTextFallback reports DOCUMENT_UNAVAILABLE when document missing", {
timeout: 5000,
}, () => {
const originalDoc = globalThis.document;
(globalThis as unknown as { document?: Document }).document = undefined;
try {
const result = copyTextFallback("x");
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
}
});
});
describe("readTextFallback", () => {
it("readTextFallback returns pasted value when paste event fires", {
timeout: 5000,
}, async () => {
const promise = readTextFallback(500);
// Wait one tick for the host to be staged.
setTimeout(() => {
const ok = dispatchSyntheticPaste("pasted-text");
expect(ok).toBe(true);
}, 10);
const result = await promise;
expect(result).toEqual({
success: true,
data: "pasted-text",
method: "fallback",
});
});
it("readTextFallback returns empty string when paste lacks text/plain", {
timeout: 5000,
}, async () => {
const promise = readTextFallback(500);
setTimeout(() => {
const candidates = document.querySelectorAll(
'textarea[aria-hidden="true"]'
);
const host = candidates.item(candidates.length - 1);
const ev = new ClipboardEvent("paste", {
clipboardData: null as unknown as DataTransfer,
bubbles: true,
cancelable: true,
});
host?.dispatchEvent(ev);
}, 5);
const result = await promise;
expect(result.success).toBe(true);
expect(result.data).toBe("");
});
it("readTextFallback returns READ_FAILED on timeout", {
timeout: 5000,
}, async () => {
const result = await readTextFallback(20);
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.READ_FAILED);
expect(result.method).toBe("fallback");
});
it("readTextFallback reports DOCUMENT_UNAVAILABLE when document missing", {
timeout: 5000,
}, async () => {
const originalDoc = globalThis.document;
(globalThis as unknown as { document?: Document }).document = undefined;
try {
const result = await readTextFallback(100);
expect(result.code).toBe(CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
}
});
it("ignores duplicate paste events after settling", {
timeout: 5000,
}, async () => {
const promise = readTextFallback(500);
// Capture host reference inside setTimeout so it has been staged.
let host: Element | null = null;
const makeEvent = (text: string): ClipboardEvent => {
const dt = new DataTransfer();
dt.setData("text/plain", text);
return new ClipboardEvent("paste", {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
};
setTimeout(() => {
const candidates = document.querySelectorAll(
'textarea[aria-hidden="true"]'
);
host = candidates.item(candidates.length - 1);
// First event resolves the promise.
host?.dispatchEvent(makeEvent("first"));
// Second event hits the settled-guard branch — host has been
// detached from DOM by cleanup, but the listener remains attached
// to the orphan node and re-fires onPaste, which short-circuits.
host?.dispatchEvent(makeEvent("second"));
}, 10);
const result = await promise;
expect(result.data).toBe("first");
});
});
describe("readTextFallback timeout idempotence", () => {
it("timeout firing after paste does not double-resolve", {
timeout: 5000,
}, async () => {
// Short timeout so it fires synchronously after paste.
const promise = readTextFallback(30);
setTimeout(() => dispatchSyntheticPaste("real"), 5);
const first = await promise;
expect(first.data).toBe("real");
// Allow the original 30ms timeout to elapse — it must noop.
await new Promise<void>((r) => setTimeout(r, 50));
expect(first.success).toBe(true);
});
});
describe("dispatchSyntheticPaste", () => {
it("returns false when no host exists", { timeout: 5000 }, () => {
// Remove any leftover hosts.
for (const host of document.querySelectorAll(
'textarea[aria-hidden="true"]'
)) {
host.parentNode?.removeChild(host);
}
expect(dispatchSyntheticPaste("x")).toBe(false);
});
it("returns false when document missing", { timeout: 5000 }, () => {
const originalDoc = globalThis.document;
(globalThis as unknown as { document?: Document }).document = undefined;
try {
expect(dispatchSyntheticPaste("x")).toBe(false);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
}
});
});

View File

@@ -0,0 +1,175 @@
import {
asClipboardError,
CLIPBOARD_CODES,
ClipboardError,
} from "../errors/errors.js";
import type { ClipboardResult } from "../types/clipboard.types.js";
/**
* Stage a hidden textarea for the `execCommand` fallback. Off-screen and
* non-interactive so it can never steal focus visibly. Caller is
* responsible for removing it via the returned cleanup.
*/
function stageTextarea(value: string): {
textarea: HTMLTextAreaElement;
cleanup: () => void;
} {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
return {
textarea,
cleanup: (): void => {
textarea.remove();
},
};
}
/**
* Copy `text` via the legacy `<textarea>` + `document.execCommand("copy")`
* path. Returns a structured {@link ClipboardResult} with method
* `"fallback"` and (on failure) a `CLIPBOARD.*` code.
*/
export function copyTextFallback(text: string): ClipboardResult<string> {
if (typeof document === "undefined") {
const error = new ClipboardError(
CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE,
"document is undefined; cannot run clipboard fallback."
);
return {
success: false,
error: error.message,
code: error.code,
method: "fallback",
};
}
const { cleanup } = stageTextarea(text);
try {
const ok = document.execCommand("copy");
if (ok) {
return { success: true, data: text, method: "fallback" };
}
return {
success: false,
error: "document.execCommand('copy') returned false.",
code: CLIPBOARD_CODES.FALLBACK_FAILED,
method: "fallback",
};
} catch (cause) {
const error = asClipboardError(
CLIPBOARD_CODES.FALLBACK_FAILED,
cause,
"document.execCommand('copy') threw."
);
return {
success: false,
error: error.message,
code: error.code,
method: "fallback",
};
} finally {
cleanup();
}
}
/**
* Read plain text from the clipboard via a paste-event fallback. Creates a
* hidden contenteditable element, focuses it, and resolves with the value
* carried by the first `paste` event. Times out with
* `CLIPBOARD.READ_FAILED` after `timeoutMs` milliseconds.
*/
export function readTextFallback(
timeoutMs: number
): Promise<ClipboardResult<string>> {
if (typeof document === "undefined") {
const error = new ClipboardError(
CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE,
"document is undefined; cannot run paste-event fallback."
);
return Promise.resolve({
success: false,
error: error.message,
code: error.code,
method: "fallback",
});
}
return new Promise<ClipboardResult<string>>((resolve) => {
const host = document.createElement("textarea");
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "-9999px";
host.style.top = "-9999px";
host.style.opacity = "0";
document.body.appendChild(host);
let settled = false;
const cleanup = (): void => {
// Keep the listener attached after first paste; the `settled`
// guard below prevents double-resolve when a host that's already
// been detached fires a duplicate paste event.
host.remove();
};
const onPaste = (ev: Event): void => {
if (settled) {
return;
}
settled = true;
const data =
(ev as ClipboardEvent).clipboardData?.getData("text/plain") ??
"";
cleanup();
resolve({ success: true, data, method: "fallback" });
};
host.addEventListener("paste", onPaste);
host.focus();
setTimeout(() => {
if (settled) {
return;
}
settled = true;
cleanup();
const error = new ClipboardError(
CLIPBOARD_CODES.READ_FAILED,
"Paste-event fallback timed out before any paste event fired."
);
resolve({
success: false,
error: error.message,
code: error.code,
method: "fallback",
});
}, timeoutMs);
});
}
/**
* Dispatch a synthetic `paste` event on the most recent hidden host (the
* one staged by `readTextFallback`). Intended for tests: the host is the
* last child of `document.body` whose `aria-hidden` attribute is `"true"`.
*/
export function dispatchSyntheticPaste(text: string): boolean {
if (typeof document === "undefined") {
return false;
}
const candidates = document.querySelectorAll(
'textarea[aria-hidden="true"]'
);
const host = candidates.item(candidates.length - 1);
if (host === null) {
return false;
}
const dt = new DataTransfer();
dt.setData("text/plain", text);
const ev = new ClipboardEvent("paste", {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
host.dispatchEvent(ev);
return true;
}

View File

@@ -0,0 +1,5 @@
export {
copyTextFallback,
dispatchSyntheticPaste,
readTextFallback,
} from "./fallback.js";

View File

@@ -0,0 +1,96 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as pkg from "./index.js";
const PKG_ROOT = resolve(__dirname, "..");
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) {
yield* walk(full);
} else {
yield full;
}
}
}
describe("@suaveplan/clipboard package surface", () => {
it("exposes the documented public symbols", { timeout: 5000 }, () => {
const exported = Object.keys(pkg).sort();
const required = [
"AsyncQueue",
"CLIPBOARD_CODES",
"CLIPBOARD_ERROR_METADATA",
"ClipboardError",
"ClipboardManager",
"DEFAULT_CLIPBOARD_OPTIONS",
"asClipboardError",
"checkPermissions",
"copyTextFallback",
"createClipboardObservability",
"createTagAllowlistSanitizer",
"dispatchSyntheticPaste",
"identitySanitizer",
"isSecure",
"onPermissionChange",
"readTextFallback",
"requireSecureContext",
"waitForNextFocus",
];
for (const sym of required) {
expect(exported).toContain(sym);
}
});
it("package.json exposes one subpath per feature module", {
timeout: 5000,
}, () => {
const manifest = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const subpaths = Object.keys(manifest.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
// Expect a subpath for each feature plus the root barrel.
expect(subpaths.length).toBeGreaterThanOrEqual(9);
for (const sub of subpaths) {
const entry = manifest.exports[sub] as Record<string, unknown>;
expect(entry).toBeDefined();
}
});
it("package has no node imports", { timeout: 5000 }, () => {
const src = resolve(__dirname);
for (const file of walk(src)) {
if (!file.endsWith(".ts")) continue;
if (file.endsWith(".test.ts")) continue;
const text = readFileSync(file, "utf8");
expect(text.includes('from "node:')).toBe(false);
expect(text.includes("require('node:")).toBe(false);
}
});
it("every implementation file has a sibling .md", {
timeout: 5000,
}, () => {
const src = resolve(__dirname);
const missing: string[] = [];
for (const file of walk(src)) {
if (!file.endsWith(".ts")) continue;
if (
file.endsWith(".test.ts") ||
file.endsWith(".types.ts") ||
file.endsWith("/index.ts")
)
continue;
const md = file.replace(/\.ts$/, ".md");
if (!existsSync(md)) {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
});

View File

@@ -0,0 +1,69 @@
/**
* `@suaveplan/clipboard` — enterprise Clipboard API abstraction.
*
* Browser-tier facade over `navigator.clipboard` with a textarea +
* `execCommand` fallback, paste-event read fallback, async FIFO
* serialization, secure-context guard, image / blob copy + read via
* `ClipboardItem`, permission change subscription, focus-aware retry,
* sanitizer hook, OpenTelemetry instrumentation, and a typed error
* hierarchy under the `CLIPBOARD.*` namespace.
*/
// Errors
export {
asClipboardError,
CLIPBOARD_CODES,
CLIPBOARD_ERROR_METADATA,
type ClipboardCodeMetadata,
ClipboardError,
type ClipboardErrorCode,
} from "./errors/errors.js";
// Fallback
export {
copyTextFallback,
dispatchSyntheticPaste,
readTextFallback,
} from "./fallback/fallback.js";
// Manager
export { ClipboardManager } from "./manager/manager.js";
// Observability
export { createClipboardObservability } from "./observability/observability.js";
export type {
ClipboardCounters,
ClipboardHistograms,
ClipboardObservability,
ClipboardObservabilityOptions,
} from "./observability/observability.types.js";
// Permissions
export {
checkPermissions,
onPermissionChange,
} from "./permissions/permissions.js";
// Queue
export { AsyncQueue } from "./queue/queue.js";
// Sanitizer
export {
createTagAllowlistSanitizer,
identitySanitizer,
} from "./sanitizer/sanitizer.js";
// Security
export {
isSecure,
requireSecureContext,
waitForNextFocus,
} from "./security/security.js";
// Types
export {
type ClipboardBlobMap,
type ClipboardData,
type ClipboardNavigator,
type ClipboardOptions,
type ClipboardPermissionState,
type ClipboardResult,
type ClipboardSanitizer,
type ClipboardWindowLike,
DEFAULT_CLIPBOARD_OPTIONS,
type PermissionChangeListener,
type PermissionResult,
type PermissionStatusLike,
} from "./types/clipboard.types.js";

View File

@@ -0,0 +1,96 @@
# `manager/helpers.ts`
## Purpose
Pure helpers shared between `manager.ts` and `operations.ts`. Keeps both
files under the line budget by housing the navigator / window resolution,
the structured failure builder, the focus-error detector, and the
`ClipboardItem` build + read pipelines in a single, side-effect-free
module that tests can exercise directly.
## Features
- `resolveNavigator(nav?)` — caller, global, or empty shim.
- `resolveWindow(win?)` — caller, global, or `undefined`.
- `failure(code, message, method)` — typed `ClipboardResult` failure.
- `isFocusError(cause)` — detects the canonical focus-required rejection.
- `buildClipboardItems(data)` — MIME → `Blob` map for `ClipboardItem`.
- `readClipboardItems(items, sanitizer?)` — pulls text / html / image /
blobs out of a list of `ClipboardItem` instances.
## Usage: Basic
```ts
import { failure, CLIPBOARD_CODES } from "@suaveplan/clipboard";
const r = failure(CLIPBOARD_CODES.NO_CONTENT, "empty", "api");
```
## Usage: Advanced
```ts
import {
buildClipboardItems,
readClipboardItems,
} from "@suaveplan/clipboard";
const items = buildClipboardItems({
text: "hello",
html: "<b>hi</b>",
image: pngBlob,
blobs: { "application/json": jsonBlob },
});
const clipboardItem = new ClipboardItem(items);
await navigator.clipboard.write([clipboardItem]);
// Round-trip back:
const read = await navigator.clipboard.read();
const data = await readClipboardItems(read, (html) => html.trim());
```
## API Reference
### `resolveNavigator(nav?: ClipboardNavigator): ClipboardNavigator`
Returns `nav` when defined, otherwise the global `navigator`, otherwise
the empty object `{}`.
### `resolveWindow(win?: ClipboardWindowLike): ClipboardWindowLike | undefined`
Returns `win` when defined, otherwise the global `window`, otherwise
`undefined`.
### `failure<T>(code, message, method): ClipboardResult<T>`
Returns `{ success: false, error: message, code, method }`. Generic over
`T` so call sites compose with their own result types.
### `isFocusError(cause: unknown): boolean`
Returns `true` when `cause` is an `Error` whose `name === "NotAllowedError"`
and whose `message` (case-insensitively) contains `"focus"`. The browser
signals "Clipboard needs page focus" with this exact shape.
### `buildClipboardItems(data: ClipboardData): Record<string, Blob>`
Builds a MIME → Blob map for `new ClipboardItem(...)`. Skips empty
`text` and `html`. Defaults a typeless image blob to `image/png`.
### `readClipboardItems(items, sanitizer?): Promise<ClipboardData>`
Iterates every entry of every `ClipboardItem`, populating `text`,
`html`, `image`, and `blobs`. The first image-typed entry wins. The
`sanitizer` (when supplied) is applied to the raw `text/html` payload
before returning.
## Implementation Notes
The helpers are intentionally side-effect-free and free of telemetry
calls so the same code runs from `runCopyText`, `runCopyRich`, etc.,
without double-recording counters. `buildClipboardItems` and
`readClipboardItems` are mirror images of each other — every MIME slot
that `buildClipboardItems` writes is read back by `readClipboardItems`
into the same `ClipboardData` shape. The focus-error detector matches
both Chromium ("Document is not focused.") and WebKit ("Document not
focused.") wording.

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES } from "../errors/errors.js";
import {
buildClipboardItems,
failure,
isFocusError,
readClipboardItems,
resolveNavigator,
resolveWindow,
} from "./helpers.js";
class FakeClipboardItem {
public readonly types: string[];
constructor(public readonly items: Record<string, Blob>) {
this.types = Object.keys(items);
}
async getType(type: string): Promise<Blob> {
return this.items[type] as Blob;
}
}
describe("resolveNavigator", () => {
it("returns supplied navigator", { timeout: 5000 }, () => {
const nav = { clipboard: undefined };
expect(resolveNavigator(nav)).toBe(nav);
});
it("falls back to global navigator", { timeout: 5000 }, () => {
expect(resolveNavigator()).toBeDefined();
});
it("returns empty object when global navigator missing", {
timeout: 5000,
}, () => {
const original = (globalThis as unknown as { navigator?: Navigator })
.navigator;
(globalThis as unknown as { navigator?: Navigator }).navigator =
undefined;
try {
expect(resolveNavigator()).toEqual({});
} finally {
(globalThis as unknown as { navigator?: Navigator }).navigator =
original;
}
});
});
describe("resolveWindow", () => {
it("returns supplied window", { timeout: 5000 }, () => {
const win = { isSecureContext: true };
expect(resolveWindow(win)).toBe(win);
});
it("falls back to global window", { timeout: 5000 }, () => {
expect(resolveWindow()).toBeDefined();
});
it("returns undefined when global window missing", {
timeout: 5000,
}, () => {
const original = (globalThis as unknown as { window?: Window }).window;
(globalThis as unknown as { window?: Window }).window = undefined;
try {
expect(resolveWindow()).toBeUndefined();
} finally {
(globalThis as unknown as { window?: Window }).window = original;
}
});
});
describe("failure", () => {
it("builds a structured failure", { timeout: 5000 }, () => {
const r = failure(CLIPBOARD_CODES.NO_CONTENT, "empty", "api");
expect(r).toEqual({
success: false,
error: "empty",
code: CLIPBOARD_CODES.NO_CONTENT,
method: "api",
});
});
});
describe("isFocusError", () => {
it("recognises NotAllowedError with 'focus' in the message", {
timeout: 5000,
}, () => {
const err = new Error("Document is not focused.");
err.name = "NotAllowedError";
expect(isFocusError(err)).toBe(true);
});
it("rejects other names", { timeout: 5000 }, () => {
const err = new Error("Document is not focused.");
expect(isFocusError(err)).toBe(false);
});
it("rejects NotAllowedError without focus phrase", {
timeout: 5000,
}, () => {
const err = new Error("blocked");
err.name = "NotAllowedError";
expect(isFocusError(err)).toBe(false);
});
it("rejects non-Error throwables", { timeout: 5000 }, () => {
expect(isFocusError("focus")).toBe(false);
});
});
describe("buildClipboardItems", () => {
it("includes provided text, html, image, and blobs", {
timeout: 5000,
}, () => {
const png = new Blob([new Uint8Array([1])], { type: "image/png" });
const json = new Blob(['{"a":1}'], { type: "application/json" });
const items = buildClipboardItems({
text: "t",
html: "<b>x</b>",
image: png,
blobs: { "application/json": json },
});
expect(Object.keys(items).sort()).toEqual(
["application/json", "image/png", "text/html", "text/plain"].sort()
);
});
it("skips empty text and empty html", { timeout: 5000 }, () => {
const items = buildClipboardItems({ text: "", html: "" });
expect(items).toEqual({});
});
it("defaults missing image MIME to image/png", { timeout: 5000 }, () => {
const blob = new Blob([new Uint8Array([1])], { type: "" });
const items = buildClipboardItems({ image: blob });
expect("image/png" in items).toBe(true);
});
});
describe("readClipboardItems", () => {
it("reads text/html/image/arbitrary blob from a single item", {
timeout: 5000,
}, async () => {
const png = new Blob([new Uint8Array([1])], { type: "image/png" });
const json = new Blob(['{"a":1}'], { type: "application/json" });
const item = new FakeClipboardItem({
"text/plain": new Blob(["t"], { type: "text/plain" }),
"text/html": new Blob(["<b>x</b>"], { type: "text/html" }),
"image/png": png,
"application/json": json,
}) as unknown as ClipboardItem;
const data = await readClipboardItems([item], undefined);
expect(data.text).toBe("t");
expect(data.html).toBe("<b>x</b>");
expect(data.image).toBeDefined();
expect(data.blobs?.["application/json"]).toBeDefined();
});
it("applies sanitizer when provided", { timeout: 5000 }, async () => {
const item = new FakeClipboardItem({
"text/html": new Blob(["<p>ok</p><script>bad</script>"], {
type: "text/html",
}),
}) as unknown as ClipboardItem;
const data = await readClipboardItems([item], (html) =>
html.replace(/<script[\s\S]*?<\/script>/g, "")
);
expect(data.html).toBe("<p>ok</p>");
});
it("returns empty object when no recognised types present", {
timeout: 5000,
}, async () => {
const item = new FakeClipboardItem({}) as unknown as ClipboardItem;
const data = await readClipboardItems([item], undefined);
expect(data).toEqual({});
});
});

View File

@@ -0,0 +1,119 @@
import type { ClipboardErrorCode } from "../errors/errors.js";
import type {
ClipboardData,
ClipboardNavigator,
ClipboardResult,
ClipboardWindowLike,
} from "../types/clipboard.types.js";
/** Resolve the effective navigator object — caller-supplied, global, or empty shim. */
export function resolveNavigator(nav?: ClipboardNavigator): ClipboardNavigator {
if (nav !== undefined) {
return nav;
}
if (typeof navigator !== "undefined") {
return navigator as unknown as ClipboardNavigator;
}
return {};
}
/** Resolve the effective window object — caller-supplied, global, or undefined. */
export function resolveWindow(
win?: ClipboardWindowLike
): ClipboardWindowLike | undefined {
if (win !== undefined) {
return win;
}
if (typeof window !== "undefined") {
return window as unknown as ClipboardWindowLike;
}
return undefined;
}
/** Build a failure {@link ClipboardResult} carrying the given code + message + method. */
export function failure<T>(
code: ClipboardErrorCode,
message: string,
method: "api" | "fallback"
): ClipboardResult<T> {
return {
success: false,
error: message,
code,
method,
};
}
/**
* Detect whether the supplied cause is a `NotAllowedError` with a message
* mentioning focus — the canonical browser signal that the page must
* regain focus before the Clipboard API will run.
*/
export function isFocusError(cause: unknown): boolean {
if (!(cause instanceof Error)) {
return false;
}
return (
cause.name === "NotAllowedError" &&
cause.message.toLowerCase().includes("focus")
);
}
/** Build a MIME -> Blob map from {@link ClipboardData} for `ClipboardItem`. */
export function buildClipboardItems(data: ClipboardData): Record<string, Blob> {
const items: Record<string, Blob> = {};
if (data.text !== undefined && data.text !== "") {
items["text/plain"] = new Blob([data.text], { type: "text/plain" });
}
if (data.html !== undefined && data.html !== "") {
items["text/html"] = new Blob([data.html], { type: "text/html" });
}
if (data.image !== undefined) {
const mime = data.image.type !== "" ? data.image.type : "image/png";
items[mime] = data.image;
}
if (data.blobs !== undefined) {
for (const [mime, blob] of Object.entries(data.blobs)) {
items[mime] = blob;
}
}
return items;
}
/**
* Walk a list of `ClipboardItem` and collect text / html / image / arbitrary
* blob entries into a {@link ClipboardData}. Applies `sanitizer` to the
* `text/html` payload when supplied.
*/
export async function readClipboardItems(
items: readonly ClipboardItem[],
sanitizer: ((html: string) => string) | undefined
): Promise<ClipboardData> {
let text: string | undefined;
let html: string | undefined;
let image: Blob | undefined;
const blobs: Record<string, Blob> = {};
for (const item of items) {
for (const type of item.types) {
const blob = await item.getType(type);
if (type === "text/plain") {
text = await blob.text();
} else if (type === "text/html") {
const raw = await blob.text();
html = sanitizer !== undefined ? sanitizer(raw) : raw;
} else if (type.startsWith("image/")) {
if (image === undefined) {
image = blob;
}
} else {
blobs[type] = blob;
}
}
}
return {
...(text !== undefined ? { text } : {}),
...(html !== undefined ? { html } : {}),
...(image !== undefined ? { image } : {}),
...(Object.keys(blobs).length > 0 ? { blobs } : {}),
};
}

View File

@@ -0,0 +1 @@
export { ClipboardManager } from "./manager.js";

View File

@@ -0,0 +1,142 @@
# `manager/manager.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`.
It composes the navigator + window seams, the FIFO `AsyncQueue`, the
typed error registry, the OpenTelemetry observability bundle, the
secure-context guard, the permission probe, and the fallback path into a
single ergonomic facade. Every public method records counters, the
operation latency histogram, and per-code error totals so production
deployments observe clipboard health without any caller-side wiring.
## Features
- `copyText`, `copyRichContent`, `readText`, `readRichContent` with
consistent `ClipboardResult` returns.
- `checkPermissions` + `onPermissionChange` for permission lifecycle.
- `isSupported`, `isSecure`, `dispose` for environment + lifecycle.
- Configurable serialization (`serialize: true` by default).
- Configurable secure-context guard (`requireSecureContext`).
- Optional sanitizer hook applied to inbound HTML.
- Optional pre-built observability bundle.
- Focus-aware retry on `NotAllowedError: Document is not focused`.
## Usage: Basic
```ts
import { ClipboardManager } from "@suaveplan/clipboard";
const clipboard = new ClipboardManager();
const result = await clipboard.copyText("hello");
if (!result.success) {
console.warn("copy failed:", result.code, result.error);
}
```
## Usage: Advanced
```ts
import {
ClipboardManager,
createClipboardObservability,
createTagAllowlistSanitizer,
} from "@suaveplan/clipboard";
const observability = createClipboardObservability();
const clipboard = new ClipboardManager({
useFallback: true,
requireSecureContext: true,
serialize: true,
focusRetry: true,
sanitizer: createTagAllowlistSanitizer(["p", "b", "i", "br"]),
pasteTimeoutMs: 2000,
observability,
});
const png = await loadPngBlob();
await clipboard.copyRichContent({
text: "fallback text",
html: "<b>bold</b>",
image: png,
});
const unsub = await clipboard.onPermissionChange((snapshot, name) => {
console.info("permission change", name, snapshot);
});
// later: unsub(); await clipboard.dispose();
```
## API Reference
### `new ClipboardManager(options?, nav?, win?)`
Construct a manager. `options` is merged on top of
`DEFAULT_CLIPBOARD_OPTIONS`. `nav` and `win` default to the global
`navigator` and `window`; either may be supplied as a structural shape
for SSR or unit tests.
### `copyText(text): Promise<ClipboardResult<string>>`
Copy plain text. Uses the async API when available; falls back to the
hidden-textarea path on rejection or absence when `useFallback` is true.
Short-circuits with `CLIPBOARD.INSECURE_CONTEXT` when
`requireSecureContext` is set and the bound window is insecure.
### `copyRichContent(data): Promise<ClipboardResult<ClipboardData>>`
Copy rich content (text / html / image / blobs) via `ClipboardItem`. The
fallback path requires `data.text`; rich-only payloads without text
short-circuit with `CLIPBOARD.NO_CONTENT`. Empty payloads also surface
`CLIPBOARD.NO_CONTENT`.
### `readText(): Promise<ClipboardResult<string>>`
Read plain text. Falls back to the paste-event capture when the API is
unavailable or rejects (and `useFallback` is true).
### `readRichContent(): Promise<ClipboardResult<ClipboardData>>`
Read rich content. Returns `CLIPBOARD.API_UNAVAILABLE` when no async API
is present (rich content has no usable fallback). Applies the optional
sanitizer to inbound HTML.
### `checkPermissions(): Promise<PermissionResult>`
Snapshot of `clipboard-read` + `clipboard-write` permission state.
### `onPermissionChange(cb): Promise<() => void>`
Subscribe to permission changes. Returns an unsubscribe function.
### `isSupported(): boolean`
True when `navigator.clipboard` is defined on the bound navigator.
### `isSecure(): boolean`
True when the bound window does not flag `isSecureContext: false`.
### `dispose(): Promise<void>`
Drain the internal queue. After dispose, queued operations resolve with
their current outcomes; new enqueues reject.
## Implementation Notes
The manager keeps a single `OperationContext` reference (navigator,
window, options, observability) shared with the pure operation functions
in `operations.ts`. This split keeps the class under the 300-line file
budget while preserving the public API. The internal `run` method wraps
every operation in a latency-measuring closure and either enqueues it
via the `AsyncQueue` (when `serialize: true`) or invokes it directly. The
`recordResult` helper updates counters per-call and per-error-code so
the OTel pipeline measures both the macro-shape (copy vs. read) and the
micro-shape (which `CLIPBOARD.*` code fired). The constructor is SSR-
safe: missing globals collapse to a fully-typed empty navigator and an
undefined window so import-time evaluation never throws on a server.

View File

@@ -0,0 +1,397 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES } from "../errors/errors.js";
import { createClipboardObservability } from "../observability/observability.js";
import type { ClipboardNavigator } from "../types/clipboard.types.js";
import { ClipboardManager } from "./manager.js";
interface NavOptions {
readonly writeText?: (text: string) => Promise<void>;
readonly readText?: () => Promise<string>;
readonly write?: (items: ClipboardItem[]) => Promise<void>;
readonly read?: () => Promise<ClipboardItem[]>;
}
function nav(opts: NavOptions = {}): ClipboardNavigator {
return {
clipboard: {
writeText: opts.writeText ?? (async () => {}),
readText: opts.readText ?? (async () => "default"),
write: opts.write ?? (async () => {}),
read: opts.read ?? (async () => []),
},
permissions: {
query: async () => ({ state: "granted" }),
},
};
}
describe("ClipboardManager.copyText wiring", () => {
it("copyText writes via Clipboard API and emits api metrics", {
timeout: 5000,
}, async () => {
const writes: string[] = [];
const m = new ClipboardManager(
{},
nav({
writeText: async (t) => {
writes.push(t);
},
})
);
const r = await m.copyText("hello");
expect(r).toEqual({ success: true, data: "hello", method: "api" });
expect(writes).toEqual(["hello"]);
});
it("copyText falls back to execCommand when API throws", {
timeout: 5000,
}, async () => {
const original = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
() => true;
try {
const m = new ClipboardManager(
{ useFallback: true },
nav({
writeText: async () => {
throw new Error("denied");
},
})
);
const r = await m.copyText("x");
expect(r.method).toBe("fallback");
expect(r.success).toBe(true);
} finally {
document.execCommand = original;
}
});
it("copyText reports CLIPBOARD.READ_FAILED when fallback disabled", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager(
{ useFallback: false },
nav({
writeText: async () => {
throw new Error("denied");
},
})
);
const r = await m.copyText("x");
expect(r.success).toBe(false);
expect(r.method).toBe("api");
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
});
it("copyText short-circuits with INSECURE_CONTEXT when requireSecureContext is true", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager({ requireSecureContext: true }, nav(), {
isSecureContext: false,
});
const r = await m.copyText("x");
expect(r.code).toBe(CLIPBOARD_CODES.INSECURE_CONTEXT);
});
});
describe("ClipboardManager queue serialization", () => {
it("copyText calls execute in arrival order when serialize is true", {
timeout: 5000,
}, async () => {
const order: string[] = [];
const m = new ClipboardManager(
{ serialize: true },
nav({
writeText: async (t) => {
if (t === "A") {
await new Promise<void>((r) => setTimeout(r, 20));
} else if (t === "B") {
await new Promise<void>((r) => setTimeout(r, 5));
}
order.push(t);
},
})
);
await Promise.all([m.copyText("A"), m.copyText("B"), m.copyText("C")]);
expect(order).toEqual(["A", "B", "C"]);
});
it("copyText calls run concurrently when serialize is false", {
timeout: 5000,
}, async () => {
let inFlight = 0;
let maxInFlight = 0;
const m = new ClipboardManager(
{ serialize: false },
nav({
writeText: async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise<void>((r) => setTimeout(r, 10));
inFlight -= 1;
},
})
);
await Promise.all([m.copyText("a"), m.copyText("b")]);
expect(maxInFlight).toBeGreaterThanOrEqual(2);
});
});
describe("ClipboardManager focus retry", () => {
it("retries once on NotAllowedError 'document not focused'", {
timeout: 5000,
}, async () => {
let calls = 0;
const m = new ClipboardManager(
{ focusRetry: true, useFallback: false },
nav({
writeText: async () => {
calls += 1;
if (calls === 1) {
const err = new Error("Document is not focused.");
err.name = "NotAllowedError";
throw err;
}
},
}),
{
isSecureContext: true,
addEventListener: (_type: "focus", cb: () => void): void => {
setTimeout(cb, 5);
},
removeEventListener: (): void => {},
}
);
const r = await m.copyText("retry");
expect(r.success).toBe(true);
expect(calls).toBe(2);
});
it("fails after focus retry exhaustion", { timeout: 5000 }, async () => {
const m = new ClipboardManager(
{ focusRetry: true, useFallback: false },
nav({
writeText: async () => {
const err = new Error("Document is not focused.");
err.name = "NotAllowedError";
throw err;
},
}),
{
isSecureContext: true,
addEventListener: (_type: "focus", cb: () => void): void => {
setTimeout(cb, 5);
},
removeEventListener: (): void => {},
}
);
const r = await m.copyText("x");
expect(r.success).toBe(false);
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
});
});
describe("ClipboardManager.readText", () => {
it("reads via API", { timeout: 5000 }, async () => {
const m = new ClipboardManager(
{},
nav({ readText: async () => "clip" })
);
const r = await m.readText();
expect(r).toEqual({ success: true, data: "clip", method: "api" });
});
it("returns READ_FAILED when API rejects and fallback disabled", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager(
{ useFallback: false },
nav({
readText: async () => {
throw new Error("denied");
},
})
);
const r = await m.readText();
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
expect(r.method).toBe("api");
});
});
describe("ClipboardManager rich-content delegation", () => {
it("copyRichContent delegates through the manager.run pipeline", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager({}, nav());
const r = await m.copyRichContent({ text: "x" });
expect(r.success).toBe(true);
expect(r.method).toBe("api");
});
it("readRichContent delegates through the manager.run pipeline", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager({}, nav());
const r = await m.readRichContent();
expect(r.success).toBe(true);
});
});
describe("ClipboardManager observability", () => {
it("clipboard_error_total advances when copyText fails", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager(
{ useFallback: false },
{ clipboard: undefined }
);
const obs = (
m as unknown as {
obs: {
counters: {
errorTotal: {
add: (n: number, a: object) => void;
};
};
};
}
).obs;
const adds: object[] = [];
const original = obs.counters.errorTotal.add.bind(
obs.counters.errorTotal
);
obs.counters.errorTotal.add = (n: number, a: object) => {
adds.push(a);
original(n, a);
};
await m.copyText("x");
expect(
adds.some(
(a) =>
(a as { code?: string }).code ===
CLIPBOARD_CODES.API_UNAVAILABLE
)
).toBe(true);
});
it("clipboard_op_latency_ms records observations for copyText", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager({}, nav());
const obs = (
m as unknown as {
obs: {
histograms: {
opLatencyMs: {
record: (ms: number, attrs: object) => void;
};
};
};
}
).obs;
const observations: Array<{ ms: number; attrs: object }> = [];
const original = obs.histograms.opLatencyMs.record.bind(
obs.histograms.opLatencyMs
);
obs.histograms.opLatencyMs.record = (ms: number, attrs: object) => {
observations.push({ ms, attrs });
original(ms, attrs);
};
await m.copyText("x");
expect(observations.length).toBeGreaterThanOrEqual(1);
expect((observations[0]?.attrs as { op: string }).op).toBe("copyText");
});
});
describe("ClipboardManager.checkPermissions + onPermissionChange", () => {
it("checkPermissions returns granted state", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager({}, nav());
const r = await m.checkPermissions();
expect(r).toEqual({ read: "granted", write: "granted" });
});
it("onPermissionChange returns an unsubscribe function", {
timeout: 5000,
}, async () => {
const m = new ClipboardManager({}, nav());
const unsub = await m.onPermissionChange(() => {});
expect(typeof unsub).toBe("function");
unsub();
});
});
describe("ClipboardManager misc", () => {
it("options merge with DEFAULT_CLIPBOARD_OPTIONS", {
timeout: 5000,
}, () => {
const m = new ClipboardManager({ useFallback: false }, nav());
expect(m.isSupported()).toBe(true);
expect(m.isSecure()).toBe(true);
});
it("accepts a pre-built observability bundle", { timeout: 5000 }, () => {
const observability = createClipboardObservability();
const m = new ClipboardManager({ observability }, nav());
expect(m.isSupported()).toBe(true);
});
it("manager survives missing navigator global", {
timeout: 5000,
}, async () => {
const original = (globalThis as unknown as { navigator?: Navigator })
.navigator;
(globalThis as unknown as { navigator?: Navigator }).navigator =
undefined;
try {
const m = new ClipboardManager();
expect(m.isSupported()).toBe(false);
} finally {
(globalThis as unknown as { navigator?: Navigator }).navigator =
original;
}
});
it("isSupported returns false when clipboard absent", {
timeout: 5000,
}, () => {
const m = new ClipboardManager({}, { clipboard: undefined });
expect(m.isSupported()).toBe(false);
});
it("isSecure delegates to window", { timeout: 5000 }, () => {
const m = new ClipboardManager({}, nav(), { isSecureContext: false });
expect(m.isSecure()).toBe(false);
});
it("dispose drains queue", { timeout: 5000 }, async () => {
const m = new ClipboardManager({}, nav());
await m.dispose();
});
it("ClipboardManager constructor is SSR-safe", {
timeout: 5000,
}, () => {
const originalDoc = globalThis.document;
const originalNav = (globalThis as unknown as { navigator?: Navigator })
.navigator;
const originalWin = (globalThis as unknown as { window?: Window })
.window;
(globalThis as unknown as { document?: Document }).document = undefined;
(globalThis as unknown as { navigator?: Navigator }).navigator =
undefined;
(globalThis as unknown as { window?: Window }).window = undefined;
try {
const m = new ClipboardManager();
expect(m.isSupported()).toBe(false);
expect(m.isSecure()).toBe(true);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
(globalThis as unknown as { navigator?: Navigator }).navigator =
originalNav;
(globalThis as unknown as { window?: Window }).window = originalWin;
}
});
});

View File

@@ -0,0 +1,144 @@
import { createClipboardObservability } from "../observability/observability.js";
import type { ClipboardObservability } from "../observability/observability.types.js";
import {
checkPermissions,
onPermissionChange,
} from "../permissions/permissions.js";
import { AsyncQueue } from "../queue/queue.js";
import { isSecure } from "../security/security.js";
import type {
ClipboardData,
ClipboardNavigator,
ClipboardOptions,
ClipboardResult,
ClipboardWindowLike,
PermissionChangeListener,
PermissionResult,
} from "../types/clipboard.types.js";
import { DEFAULT_CLIPBOARD_OPTIONS } from "../types/clipboard.types.js";
import { resolveNavigator, resolveWindow } from "./helpers.js";
import {
type OperationContext,
runCopyRich,
runCopyText,
runReadRich,
runReadText,
} from "./operations.js";
type Op = "copyText" | "readText" | "copyRich" | "readRich";
/**
* Enterprise ClipboardManager. Owns a navigator + options bag, an internal
* `AsyncQueue` for FIFO serialization, an observability bundle for OTel
* instrumentation, and the fallback path. Every public method records
* counters, histograms, and per-code error totals.
*/
export class ClipboardManager {
private readonly options: ClipboardOptions;
private readonly nav: ClipboardNavigator;
private readonly win: ClipboardWindowLike | undefined;
private readonly queue: AsyncQueue;
private readonly obs: ClipboardObservability;
private readonly ctx: OperationContext;
constructor(
options: Partial<ClipboardOptions> = {},
nav?: ClipboardNavigator,
win?: ClipboardWindowLike
) {
this.options = { ...DEFAULT_CLIPBOARD_OPTIONS, ...options };
this.nav = resolveNavigator(nav);
this.win = resolveWindow(win);
this.queue = new AsyncQueue();
this.obs = createClipboardObservability(
this.options.observability !== undefined
? { observability: this.options.observability }
: undefined
);
this.ctx = {
nav: this.nav,
win: this.win,
options: this.options,
obs: this.obs,
};
}
/** Whether the async Clipboard API is supported by the bound navigator. */
isSupported(): boolean {
return this.nav.clipboard !== undefined;
}
/** Whether the bound window is a secure context. */
isSecure(): boolean {
return isSecure(this.win);
}
/** Copy plain text. */
copyText(text: string): Promise<ClipboardResult<string>> {
return this.run("copyText", () => runCopyText(this.ctx, text));
}
/** Copy rich content (text / html / image / blobs). */
copyRichContent(
data: ClipboardData
): Promise<ClipboardResult<ClipboardData>> {
return this.run("copyRich", () => runCopyRich(this.ctx, data));
}
/** Read plain text; paste-event fallback when API absent. */
readText(): Promise<ClipboardResult<string>> {
return this.run("readText", () => runReadText(this.ctx));
}
/** Read rich content (text / html / image / blobs). */
readRichContent(): Promise<ClipboardResult<ClipboardData>> {
return this.run("readRich", () => runReadRich(this.ctx));
}
/** Probe current permission state for read + write. */
checkPermissions(): Promise<PermissionResult> {
return checkPermissions(this.nav);
}
/** Subscribe to permission state changes. */
onPermissionChange(cb: PermissionChangeListener): Promise<() => void> {
return onPermissionChange(this.nav, cb);
}
/** Drain the queue. */
async dispose(): Promise<void> {
await this.queue.drain();
}
private async run<T>(
op: Op,
fn: () => Promise<ClipboardResult<T>>
): Promise<ClipboardResult<T>> {
const start = Date.now();
const exec = async (): Promise<ClipboardResult<T>> => {
const result = await fn();
this.recordResult(op, result);
this.obs.histograms.opLatencyMs.record(Date.now() - start, {
op,
method: result.method,
});
return result;
};
if (this.options.serialize) {
return this.queue.enqueue(exec);
}
return exec();
}
private recordResult(op: Op, result: ClipboardResult<unknown>): void {
const kind = op === "copyText" || op === "readText" ? "text" : "rich";
const isCopy = op === "copyText" || op === "copyRich";
const counter = isCopy
? this.obs.counters.copyTotal
: this.obs.counters.readTotal;
counter.add(1, { method: result.method, kind });
if (!result.success && result.code !== undefined) {
this.obs.counters.errorTotal.add(1, { code: result.code });
}
}
}

View File

@@ -0,0 +1,113 @@
# `manager/operations.ts`
## Purpose
Pure operation functions invoked by `ClipboardManager`. Each function
takes an `OperationContext` (navigator, window, options, observability)
plus the operation-specific arguments and returns a `ClipboardResult`.
Splitting these out of the class keeps `manager.ts` under the file-size
budget and makes each path independently testable without instantiating a
full manager.
## Features
- `runCopyText(ctx, text)` — plain-text copy with secure-context gate,
API path, fallback path, and focus-aware retry.
- `runCopyRich(ctx, data)` — rich-content copy via `ClipboardItem` with
fallback-to-text behaviour and `CLIPBOARD.NO_CONTENT` guards.
- `runReadText(ctx)` — plain-text read with paste-event fallback.
- `runReadRich(ctx)` — rich-content read with sanitizer pass-through.
- Internal `tryApi` wraps every navigator call in a focus-retry-capable
envelope.
## Usage: Basic
```ts
import {
runCopyText,
type OperationContext,
createClipboardObservability,
DEFAULT_CLIPBOARD_OPTIONS,
} from "@suaveplan/clipboard";
const ctx: OperationContext = {
nav: navigator,
win: window,
options: DEFAULT_CLIPBOARD_OPTIONS,
obs: createClipboardObservability(),
};
const result = await runCopyText(ctx, "hello");
```
## Usage: Advanced
```ts
import {
runCopyRich,
type OperationContext,
DEFAULT_CLIPBOARD_OPTIONS,
createClipboardObservability,
createTagAllowlistSanitizer,
} from "@suaveplan/clipboard";
const ctx: OperationContext = {
nav: navigator,
win: window,
options: {
...DEFAULT_CLIPBOARD_OPTIONS,
sanitizer: createTagAllowlistSanitizer(["p", "b", "i"]),
requireSecureContext: true,
},
obs: createClipboardObservability(),
};
const result = await runCopyRich(ctx, {
text: "fallback text",
html: "<b>x</b>",
image: await fetch("/logo.png").then((r) => r.blob()),
});
```
## API Reference
### `OperationContext`
`{ nav, win, options, obs }`. Lifetime-bound to the manager.
### `runCopyText(ctx, text)`
Returns `Promise<ClipboardResult<string>>`. Honours
`requireSecureContext`, `useFallback`, `focusRetry`, and the fallback
counter wiring.
### `runCopyRich(ctx, data)`
Returns `Promise<ClipboardResult<ClipboardData>>`. Builds a single
`ClipboardItem` covering every populated MIME slot. Falls back to
`runCopyText` when the API rejects and `data.text` is non-empty.
### `runReadText(ctx)`
Returns `Promise<ClipboardResult<string>>`. Uses `readTextFallback` from
the `fallback/` module when the API is absent or rejects.
### `runReadRich(ctx)`
Returns `Promise<ClipboardResult<ClipboardData>>`. Walks every returned
`ClipboardItem` and populates `text` / `html` / `image` / `blobs`. The
first image wins when multiple are present. The optional sanitizer
mutates `html` before returning.
## Implementation Notes
The functions are exported (not module-private) so the test suite can
exercise each branch in isolation without driving a full `ClipboardManager`.
The `tryApi` helper centralises the focus-retry logic: a single
`NotAllowedError` whose message mentions "focus" triggers one
`waitForNextFocus` + retry. Any other rejection skips the retry path and
lifts the cause via `asClipboardError(CLIPBOARD.READ_FAILED, ...)`. The
fallback counter (`clipboard_fallback_used_total`) is incremented with a
`reason` attribute (`"api_threw"`, `"no_api"`, `"fallback_failed"`) at the
exact transition point so operators can distinguish "fallback because API
returned no" from "fallback because API rejected" — a critical signal
when diagnosing partial outages.

View File

@@ -0,0 +1,476 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES } from "../errors/errors.js";
import { createClipboardObservability } from "../observability/observability.js";
import type {
ClipboardData,
ClipboardNavigator,
} from "../types/clipboard.types.js";
import { DEFAULT_CLIPBOARD_OPTIONS } from "../types/clipboard.types.js";
import {
type OperationContext,
runCopyRich,
runCopyText,
runReadRich,
runReadText,
} from "./operations.js";
class FakeClipboardItem {
public readonly types: string[];
constructor(public readonly items: Record<string, Blob>) {
this.types = Object.keys(items);
}
async getType(type: string): Promise<Blob> {
const blob = this.items[type];
if (blob === undefined) {
throw new Error(`no ${type}`);
}
return blob;
}
}
(
globalThis as unknown as { ClipboardItem: typeof ClipboardItem }
).ClipboardItem = FakeClipboardItem as unknown as typeof ClipboardItem;
interface NavOptions {
readonly writeText?: (text: string) => Promise<void>;
readonly readText?: () => Promise<string>;
readonly write?: (items: ClipboardItem[]) => Promise<void>;
readonly read?: () => Promise<ClipboardItem[]>;
}
function makeNav(opts: NavOptions = {}): ClipboardNavigator {
return {
clipboard: {
writeText: opts.writeText ?? (async () => {}),
readText: opts.readText ?? (async () => "default"),
write: opts.write ?? (async () => {}),
read: opts.read ?? (async () => []),
},
};
}
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
return {
nav: overrides.nav ?? makeNav(),
win: overrides.win,
options: overrides.options ?? { ...DEFAULT_CLIPBOARD_OPTIONS },
obs: overrides.obs ?? createClipboardObservability(),
};
}
describe("runCopyRich rich-content paths", () => {
it("copyRichContent writes text html and image in one ClipboardItem", {
timeout: 5000,
}, async () => {
let written: ClipboardItem[] = [];
const png = new Blob([new Uint8Array([1, 2, 3])], {
type: "image/png",
});
const ctx = makeCtx({
nav: makeNav({
write: async (items) => {
written = items;
},
}),
});
const data: ClipboardData = {
text: "hello",
html: "<b>hi</b>",
image: png,
};
const r = await runCopyRich(ctx, data);
expect(r.success).toBe(true);
expect(r.method).toBe("api");
expect(written).toHaveLength(1);
const item = written[0] as unknown as FakeClipboardItem;
expect(item.types.sort()).toEqual(
["image/png", "text/html", "text/plain"].sort()
);
});
it("copyRichContent returns NO_CONTENT for empty data", {
timeout: 5000,
}, async () => {
const r = await runCopyRich(makeCtx(), {});
expect(r.code).toBe(CLIPBOARD_CODES.NO_CONTENT);
});
it("copyRichContent fallback rejects html-only payload as NO_CONTENT", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
nav: makeNav({
write: async () => {
throw new Error("denied");
},
}),
});
const r = await runCopyRich(ctx, { html: "<b>x</b>" });
expect(r.method).toBe("fallback");
expect(r.code).toBe(CLIPBOARD_CODES.NO_CONTENT);
});
it("falls back via text when API write throws and useFallback true", {
timeout: 5000,
}, async () => {
const original = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
() => true;
try {
const ctx = makeCtx({
nav: makeNav({
write: async () => {
throw new Error("denied");
},
}),
});
const r = await runCopyRich(ctx, {
text: "t",
html: "<b>x</b>",
});
expect(r.success).toBe(true);
expect(r.method).toBe("fallback");
expect(r.data).toEqual({ text: "t", html: "<b>x</b>" });
} finally {
document.execCommand = original;
}
});
it("reports READ_FAILED when api throws and fallback disabled", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
options: { ...DEFAULT_CLIPBOARD_OPTIONS, useFallback: false },
nav: makeNav({
write: async () => {
throw new Error("denied");
},
}),
});
const r = await runCopyRich(ctx, { text: "t" });
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
});
it("short-circuits with INSECURE_CONTEXT when requireSecureContext is true", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
options: {
...DEFAULT_CLIPBOARD_OPTIONS,
requireSecureContext: true,
},
win: { isSecureContext: false },
});
const r = await runCopyRich(ctx, { text: "x" });
expect(r.code).toBe(CLIPBOARD_CODES.INSECURE_CONTEXT);
});
it("accepts arbitrary MIME blobs", { timeout: 5000 }, async () => {
let written: ClipboardItem[] = [];
const json = new Blob(['{"a":1}'], { type: "application/json" });
const ctx = makeCtx({
nav: makeNav({
write: async (items) => {
written = items;
},
}),
});
const r = await runCopyRich(ctx, {
blobs: { "application/json": json },
});
expect(r.success).toBe(true);
const item = written[0] as unknown as FakeClipboardItem;
expect(item.types).toContain("application/json");
});
it("defaults image MIME to image/png when blob.type is empty", {
timeout: 5000,
}, async () => {
let written: ClipboardItem[] = [];
const blob = new Blob([new Uint8Array([1])], { type: "" });
const ctx = makeCtx({
nav: makeNav({
write: async (items) => {
written = items;
},
}),
});
const r = await runCopyRich(ctx, { image: blob });
expect(r.success).toBe(true);
expect((written[0] as unknown as FakeClipboardItem).types).toContain(
"image/png"
);
});
});
describe("runReadRich rich-content paths", () => {
it("readRichContent surfaces image and arbitrary blob fields", {
timeout: 5000,
}, async () => {
const png = new Blob([new Uint8Array([9])], { type: "image/png" });
const json = new Blob(['{"a":1}'], { type: "application/json" });
const item = new FakeClipboardItem({
"image/png": png,
"application/json": json,
}) as unknown as ClipboardItem;
const ctx = makeCtx({
nav: makeNav({ read: async () => [item] }),
});
const r = await runReadRich(ctx);
expect(r.success).toBe(true);
expect(r.data?.image).toBeInstanceOf(Blob);
expect(r.data?.blobs?.["application/json"]).toBeInstanceOf(Blob);
});
it("readRichContent reports API_UNAVAILABLE when clipboard absent", {
timeout: 5000,
}, async () => {
const r = await runReadRich(makeCtx({ nav: { clipboard: undefined } }));
expect(r.code).toBe(CLIPBOARD_CODES.API_UNAVAILABLE);
});
it("readRichContent applies sanitizer to html payload", {
timeout: 5000,
}, async () => {
const item = new FakeClipboardItem({
"text/html": new Blob(["<p>ok</p><script>x</script>"], {
type: "text/html",
}),
}) as unknown as ClipboardItem;
const ctx = makeCtx({
options: {
...DEFAULT_CLIPBOARD_OPTIONS,
sanitizer: (html) =>
html.replace(/<script[\s\S]*?<\/script>/g, ""),
},
nav: makeNav({ read: async () => [item] }),
});
const r = await runReadRich(ctx);
expect(r.data?.html).toBe("<p>ok</p>");
});
it("returns READ_FAILED when read throws", { timeout: 5000 }, async () => {
const ctx = makeCtx({
nav: makeNav({
read: async () => {
throw new Error("denied");
},
}),
});
const r = await runReadRich(ctx);
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
});
it("returns text-only data when only text/plain present", {
timeout: 5000,
}, async () => {
const item = new FakeClipboardItem({
"text/plain": new Blob(["only text"], { type: "text/plain" }),
}) as unknown as ClipboardItem;
const ctx = makeCtx({
nav: makeNav({ read: async () => [item] }),
});
const r = await runReadRich(ctx);
expect(r.data?.text).toBe("only text");
expect(r.data?.html).toBeUndefined();
});
it("retains only the first image when multiple supplied", {
timeout: 5000,
}, async () => {
const first = new Blob([new Uint8Array([1])], { type: "image/png" });
const second = new Blob([new Uint8Array([2])], { type: "image/jpeg" });
const item = new FakeClipboardItem({
"image/png": first,
"image/jpeg": second,
}) as unknown as ClipboardItem;
const ctx = makeCtx({
nav: makeNav({ read: async () => [item] }),
});
const r = await runReadRich(ctx);
expect(r.data?.image).toBe(first);
});
});
describe("focus retry behaviour", () => {
it("does not retry when focus never arrives within timeout", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
options: {
...DEFAULT_CLIPBOARD_OPTIONS,
focusRetry: true,
useFallback: false,
},
nav: makeNav({
writeText: async () => {
const err = new Error("Document is not focused.");
err.name = "NotAllowedError";
throw err;
},
}),
win: {}, // No addEventListener — focus never fires.
});
const r = await runCopyText(ctx, "x");
expect(r.success).toBe(false);
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
});
it("returns retry failure when retry itself rejects", {
timeout: 5000,
}, async () => {
let calls = 0;
const ctx = makeCtx({
options: {
...DEFAULT_CLIPBOARD_OPTIONS,
focusRetry: true,
useFallback: false,
},
nav: makeNav({
writeText: async () => {
calls += 1;
const err = new Error("Document is not focused.");
err.name = "NotAllowedError";
throw err;
},
}),
win: {
isSecureContext: true,
addEventListener: (_type: "focus", cb: () => void): void => {
setTimeout(cb, 5);
},
removeEventListener: (): void => {},
},
});
const r = await runCopyText(ctx, "x");
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
expect(calls).toBe(2);
});
});
describe("runCopyText / runReadText extra branches", () => {
it("runCopyText reports API_UNAVAILABLE when no API and fallback disabled", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
nav: { clipboard: undefined },
options: { ...DEFAULT_CLIPBOARD_OPTIONS, useFallback: false },
});
const r = await runCopyText(ctx, "x");
expect(r.code).toBe(CLIPBOARD_CODES.API_UNAVAILABLE);
});
it("runCopyText uses fallback when no API and fallback enabled", {
timeout: 5000,
}, async () => {
const original = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
() => true;
try {
const ctx = makeCtx({ nav: { clipboard: undefined } });
const r = await runCopyText(ctx, "x");
expect(r.method).toBe("fallback");
expect(r.success).toBe(true);
} finally {
document.execCommand = original;
}
});
it("runReadText falls back via paste event when API absent", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
nav: { clipboard: undefined },
options: { ...DEFAULT_CLIPBOARD_OPTIONS, pasteTimeoutMs: 20 },
});
const r = await runReadText(ctx);
expect(r.method).toBe("fallback");
// Timed out, so READ_FAILED.
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
});
it("runReadText falls back when API rejects and fallback enabled", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
options: { ...DEFAULT_CLIPBOARD_OPTIONS, pasteTimeoutMs: 20 },
nav: makeNav({
readText: async () => {
throw new Error("denied");
},
}),
});
const r = await runReadText(ctx);
expect(r.method).toBe("fallback");
});
it("runCopyRich falls back to text path when ClipboardItem is undefined", {
timeout: 5000,
}, async () => {
const original = (
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
).ClipboardItem;
(
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
).ClipboardItem = undefined;
const originalExec = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
() => true;
try {
const ctx = makeCtx();
const r = await runCopyRich(ctx, { text: "fb" });
expect(r.method).toBe("fallback");
expect(r.success).toBe(true);
} finally {
(
globalThis as unknown as {
ClipboardItem?: typeof ClipboardItem;
}
).ClipboardItem = original;
document.execCommand = originalExec;
}
});
it("runCopyRich fallback returns error fields when text fallback fails", {
timeout: 5000,
}, async () => {
const original = (
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
).ClipboardItem;
(
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
).ClipboardItem = undefined;
const originalExec = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
() => false;
try {
const ctx = makeCtx();
const r = await runCopyRich(ctx, { text: "fb" });
expect(r.success).toBe(false);
expect(r.method).toBe("fallback");
expect(r.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
} finally {
(
globalThis as unknown as {
ClipboardItem?: typeof ClipboardItem;
}
).ClipboardItem = original;
document.execCommand = originalExec;
}
});
it("runCopyText short-circuits with INSECURE_CONTEXT", {
timeout: 5000,
}, async () => {
const ctx = makeCtx({
options: {
...DEFAULT_CLIPBOARD_OPTIONS,
requireSecureContext: true,
},
win: { isSecureContext: false },
});
const r = await runCopyText(ctx, "x");
expect(r.code).toBe(CLIPBOARD_CODES.INSECURE_CONTEXT);
});
});

View File

@@ -0,0 +1,228 @@
import {
asClipboardError,
CLIPBOARD_CODES,
type ClipboardError,
} from "../errors/errors.js";
import { copyTextFallback, readTextFallback } from "../fallback/fallback.js";
import type { ClipboardObservability } from "../observability/observability.types.js";
import { waitForNextFocus } from "../security/security.js";
import type {
ClipboardData,
ClipboardNavigator,
ClipboardOptions,
ClipboardResult,
ClipboardWindowLike,
} from "../types/clipboard.types.js";
import {
buildClipboardItems,
failure,
isFocusError,
readClipboardItems,
} from "./helpers.js";
/** Per-call context the operation functions receive from {@link ClipboardManager}. */
export interface OperationContext {
readonly nav: ClipboardNavigator;
readonly win: ClipboardWindowLike | undefined;
readonly options: ClipboardOptions;
readonly obs: ClipboardObservability;
}
type TryApiResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: ClipboardError };
async function tryApi<T>(
ctx: OperationContext,
invoke: () => Promise<T>
): Promise<TryApiResult<T>> {
try {
const value = await invoke();
return { ok: true, value };
} catch (cause) {
if (ctx.options.focusRetry && isFocusError(cause)) {
const focused = await waitForNextFocus(ctx.win, 250);
if (focused) {
try {
const value = await invoke();
return { ok: true, value };
} catch (retryCause) {
return {
ok: false,
error: asClipboardError(
CLIPBOARD_CODES.READ_FAILED,
retryCause,
"Clipboard API rejected after focus retry."
),
};
}
}
}
return {
ok: false,
error: asClipboardError(
CLIPBOARD_CODES.READ_FAILED,
cause,
"Clipboard API rejected."
),
};
}
}
function isSecureCtx(ctx: OperationContext): boolean {
return ctx.win === undefined || ctx.win.isSecureContext !== false;
}
function getClipboard(
ctx: OperationContext
): NonNullable<OperationContext["nav"]["clipboard"]> | undefined {
return ctx.nav.clipboard;
}
/** Run a plain-text copy with full API + fallback + secure-context handling. */
export async function runCopyText(
ctx: OperationContext,
text: string
): Promise<ClipboardResult<string>> {
if (ctx.options.requireSecureContext && !isSecureCtx(ctx)) {
return failure(
CLIPBOARD_CODES.INSECURE_CONTEXT,
"Refusing clipboard copy in an insecure context.",
"api"
);
}
const clipboard = getClipboard(ctx);
if (clipboard !== undefined) {
const apiResult = await tryApi(ctx, () => clipboard.writeText(text));
if (apiResult.ok) {
return { success: true, data: text, method: "api" };
}
if (!ctx.options.useFallback) {
return {
success: false,
error: apiResult.error.message,
code: CLIPBOARD_CODES.READ_FAILED,
method: "api",
};
}
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "api_threw" });
} else if (!ctx.options.useFallback) {
return failure(
CLIPBOARD_CODES.API_UNAVAILABLE,
"navigator.clipboard is undefined.",
"api"
);
} else {
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "no_api" });
}
return copyTextFallback(text);
}
/** Run a rich-content copy via `ClipboardItem`. */
export async function runCopyRich(
ctx: OperationContext,
data: ClipboardData
): Promise<ClipboardResult<ClipboardData>> {
if (ctx.options.requireSecureContext && !isSecureCtx(ctx)) {
return failure(
CLIPBOARD_CODES.INSECURE_CONTEXT,
"Refusing clipboard copy in an insecure context.",
"api"
);
}
const items = buildClipboardItems(data);
if (Object.keys(items).length === 0) {
return failure(
CLIPBOARD_CODES.NO_CONTENT,
"copyRichContent called with empty ClipboardData.",
"api"
);
}
const clipboard = getClipboard(ctx);
if (clipboard !== undefined && typeof ClipboardItem !== "undefined") {
const apiResult = await tryApi(ctx, () =>
clipboard.write([new ClipboardItem(items)])
);
if (apiResult.ok) {
return { success: true, data, method: "api" };
}
if (!ctx.options.useFallback) {
return {
success: false,
error: apiResult.error.message,
code: CLIPBOARD_CODES.READ_FAILED,
method: "api",
};
}
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "api_threw" });
}
if (data.text === undefined || data.text === "") {
return failure(
CLIPBOARD_CODES.NO_CONTENT,
"Rich-content fallback requires data.text.",
"fallback"
);
}
const fb = copyTextFallback(data.text);
const result: ClipboardResult<ClipboardData> = {
success: fb.success,
...(fb.success ? { data } : {}),
...(fb.error !== undefined ? { error: fb.error } : {}),
...(fb.code !== undefined ? { code: fb.code } : {}),
method: "fallback",
};
return result;
}
/** Run a plain-text read with paste-event fallback. */
export async function runReadText(
ctx: OperationContext
): Promise<ClipboardResult<string>> {
const clipboard = getClipboard(ctx);
if (clipboard !== undefined) {
const apiResult = await tryApi(ctx, () => clipboard.readText());
if (apiResult.ok) {
return { success: true, data: apiResult.value, method: "api" };
}
if (!ctx.options.useFallback) {
return {
success: false,
error: apiResult.error.message,
code: CLIPBOARD_CODES.READ_FAILED,
method: "api",
};
}
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "api_threw" });
} else {
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "no_api" });
}
return readTextFallback(ctx.options.pasteTimeoutMs);
}
/** Run a rich-content read; returns API_UNAVAILABLE when navigator.clipboard absent. */
export async function runReadRich(
ctx: OperationContext
): Promise<ClipboardResult<ClipboardData>> {
const clipboard = getClipboard(ctx);
if (clipboard === undefined) {
return failure(
CLIPBOARD_CODES.API_UNAVAILABLE,
"navigator.clipboard is undefined; rich-content read requires the async API.",
"fallback"
);
}
const apiResult = await tryApi(ctx, () => clipboard.read());
if (!apiResult.ok) {
return {
success: false,
error: apiResult.error.message,
code: CLIPBOARD_CODES.READ_FAILED,
method: "api",
};
}
const data = await readClipboardItems(
apiResult.value,
ctx.options.sanitizer
);
return { success: true, data, method: "api" };
}

View File

@@ -0,0 +1,7 @@
export { createClipboardObservability } from "./observability.js";
export type {
ClipboardCounters,
ClipboardHistograms,
ClipboardObservability,
ClipboardObservabilityOptions,
} from "./observability.types.js";

View File

@@ -0,0 +1,95 @@
# `observability/observability.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires
four counters and one histogram on a single `Meter` plus a `Tracer`,
defaulting to the no-op providers from `@opentelemetry/api` when no
real provider is supplied. The `ClipboardManager` records into every
instrument across every public method so production deployments measure
copy / read volume, fallback usage, error rate by code, and operation
latency without any caller-side wiring beyond passing a `Meter`.
## Features
- `clipboard_copy_total{method, kind}` — counter, copy attempts.
- `clipboard_read_total{method, kind}` — counter, read attempts.
- `clipboard_fallback_used_total{reason}` — counter, fallback engagements.
- `clipboard_error_total{code}` — counter, errors by `CLIPBOARD.*` code.
- `clipboard_op_latency_ms{op, method}` — histogram, operation latency.
- Single factory: `createClipboardObservability(opts?)`.
- Caller may supply a pre-built bundle to skip instrument creation.
## Usage: Basic
```ts
import {
ClipboardManager,
createClipboardObservability,
} from "@suaveplan/clipboard";
const observability = createClipboardObservability();
const clipboard = new ClipboardManager({ observability });
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
ClipboardManager,
createClipboardObservability,
} from "@suaveplan/clipboard";
const meter = metrics.getMeterProvider().getMeter("my-app", "1.0.0");
const observability = createClipboardObservability({ meter });
const clipboard = new ClipboardManager({ observability });
// Wire the meter into your OTel SDK exporter; counters fire on every
// copy / read call without extra plumbing.
```
## API Reference
### `createClipboardObservability(opts?: ClipboardObservabilityOptions): ClipboardObservability`
Returns a bundle with the four counters and one histogram. Pass through
the caller-supplied bundle if `opts.observability` is set; otherwise use
the supplied `Meter` and `Tracer` (or the no-op global ones).
### `ClipboardObservability`
`{ meter, tracer, counters, histograms }` — strongly typed; counter and
histogram entries are OpenTelemetry `Counter` and `Histogram` instances
respectively, so consumers can call `.add(n, attrs)` and
`.record(ms, attrs)` directly when composing higher-level metrics.
### `ClipboardCounters`
- `copyTotal` — fires on every `copyText` / `copyRichContent` call.
- `readTotal` — fires on every `readText` / `readRichContent` call.
- `fallbackUsedTotal` — fires whenever the fallback path runs.
- `errorTotal` — fires once per failure, labeled by error code.
### `ClipboardHistograms`
- `opLatencyMs` — milliseconds spent inside each public method, labeled
with the `op` name and the `method` (`api` / `fallback`).
## Implementation Notes
The factory delegates to `@suaveplan/opentelemetry`'s `createCounter` and
`createHistogram` helpers so all instruments share the project's
canonical OTel wiring (descriptions, units, naming convention). The
`Meter` and `Tracer` names are pinned to the package name (`@suaveplan/clipboard`)
so observers can filter to clipboard signals without inspecting
attributes. When no provider is wired, `metrics.getMeter` returns the
no-op meter from `@opentelemetry/api`, so calling `.add` / `.record` on
the instruments is safe and cost-free. Tests assert on the instrument
shape (callable `add` / `record`) plus the pass-through behaviour of
`opts.observability` so consumers can swap implementations without
forking the factory.

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createClipboardObservability } from "./observability.js";
describe("createClipboardObservability", () => {
it("createClipboardObservability returns wired counters and histogram", {
timeout: 5000,
}, () => {
const bundle = createClipboardObservability();
expect(bundle.meter).toBeDefined();
expect(bundle.tracer).toBeDefined();
expect(typeof bundle.counters.copyTotal.add).toBe("function");
expect(typeof bundle.counters.readTotal.add).toBe("function");
expect(typeof bundle.counters.fallbackUsedTotal.add).toBe("function");
expect(typeof bundle.counters.errorTotal.add).toBe("function");
expect(typeof bundle.histograms.opLatencyMs.record).toBe("function");
});
it("returns the pre-built bundle when one is supplied", {
timeout: 5000,
}, () => {
const pre = createClipboardObservability();
const got = createClipboardObservability({ observability: pre });
expect(got).toBe(pre);
});
it("accepts a caller-supplied meter and tracer", {
timeout: 5000,
}, () => {
const base = createClipboardObservability();
const got = createClipboardObservability({
meter: base.meter,
tracer: base.tracer,
});
expect(got.meter).toBe(base.meter);
expect(got.tracer).toBe(base.tracer);
});
});

View File

@@ -0,0 +1,61 @@
import { metrics, trace } from "@opentelemetry/api";
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
import type {
ClipboardObservability,
ClipboardObservabilityOptions,
Meter,
Tracer,
} from "./observability.types.js";
const METER_NAME = "@suaveplan/clipboard";
const TRACER_NAME = "@suaveplan/clipboard";
/**
* Create a {@link ClipboardObservability} bundle on the supplied meter and
* tracer, or on the no-op providers from `@opentelemetry/api` when none are
* supplied. Safe to call from every public entrypoint even when no
* telemetry pipeline is wired up.
*/
export function createClipboardObservability(
opts?: ClipboardObservabilityOptions
): ClipboardObservability {
if (opts?.observability !== undefined) {
return opts.observability;
}
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
return {
meter,
tracer,
counters: {
copyTotal: createCounter(
meter,
"clipboard_copy_total",
"Total clipboard copy operations (success + failure)."
),
readTotal: createCounter(
meter,
"clipboard_read_total",
"Total clipboard read operations (success + failure)."
),
fallbackUsedTotal: createCounter(
meter,
"clipboard_fallback_used_total",
"Total times the textarea + execCommand fallback was engaged."
),
errorTotal: createCounter(
meter,
"clipboard_error_total",
"Total clipboard errors, partitioned by error code."
),
},
histograms: {
opLatencyMs: createHistogram(
meter,
"clipboard_op_latency_ms",
"Clipboard operation latency in milliseconds.",
"ms"
),
},
};
}

View File

@@ -0,0 +1,34 @@
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
/** Counter instruments exposed by the observability bundle. */
export interface ClipboardCounters {
readonly copyTotal: Counter;
readonly readTotal: Counter;
readonly fallbackUsedTotal: Counter;
readonly errorTotal: Counter;
}
/** Histogram instruments exposed by the observability bundle. */
export interface ClipboardHistograms {
readonly opLatencyMs: Histogram;
}
/**
* Observability bundle injected into the {@link ClipboardManager}. Includes
* the meter, tracer, and the strongly-typed instruments.
*/
export interface ClipboardObservability {
readonly meter: Meter;
readonly tracer: Tracer;
readonly counters: ClipboardCounters;
readonly histograms: ClipboardHistograms;
}
/** Options for constructing a {@link ClipboardObservability} bundle. */
export interface ClipboardObservabilityOptions {
readonly meter?: Meter;
readonly tracer?: Tracer;
readonly observability?: ClipboardObservability;
}
export type { Meter, Tracer };

View File

@@ -0,0 +1 @@
export { checkPermissions, onPermissionChange } from "./permissions.js";

View File

@@ -0,0 +1,91 @@
# `permissions/permissions.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Probe and subscribe to clipboard permission state via the browser's
Permissions API. `checkPermissions` returns a snapshot of the
`clipboard-read` and `clipboard-write` permission states with graceful
degradation to `"prompt"` when the API is missing or a query rejects.
`onPermissionChange` subscribes a callback to `PermissionStatus.change`
events on both descriptors so UI surfaces can reflect real-time grants
and revocations without polling.
## Features
- `checkPermissions(nav)` — `{ read, write }` snapshot; never throws.
- `onPermissionChange(nav, cb)` — subscribe with one callback, unsubscribe
with a single returned function.
- Tolerant of partial API support: falls back to `PermissionStatus.onchange`
when `addEventListener` is unavailable.
- Tolerant of permission-query rejection: skips that slot without
re-throwing.
## Usage: Basic
```ts
import { checkPermissions } from "@suaveplan/clipboard";
const { read, write } = await checkPermissions(navigator);
if (read === "denied" || write === "denied") {
showFallbackUi();
}
```
## Usage: Advanced
```ts
import { onPermissionChange } from "@suaveplan/clipboard";
const unsubscribe = await onPermissionChange(
navigator,
(snapshot, descriptor) => {
console.info(
"[clipboard] permission change",
descriptor,
snapshot,
);
if (snapshot.read === "granted" && snapshot.write === "granted") {
enableCopyButtons();
}
},
);
// later — on unmount / dispose
unsubscribe();
```
## API Reference
### `checkPermissions(nav: ClipboardNavigator): Promise<PermissionResult>`
Resolves with `{ read, write }`. Each slot is one of `"granted"`,
`"denied"`, `"prompt"`. Returns `{ read: "prompt", write: "prompt" }`
when `nav.permissions` is undefined, and `"prompt"` for any individual
descriptor whose `query()` rejects or returns an unknown state.
### `onPermissionChange(nav, cb): Promise<() => void>`
Subscribes `cb` to permission changes on both `clipboard-read` and
`clipboard-write`. Returns an unsubscribe function. The callback receives
the latest `PermissionResult` snapshot plus the descriptor name whose
state changed. The change handler internally re-runs `checkPermissions`
so both `read` and `write` are always up to date on every callback.
## Implementation Notes
The Permissions API exposes two listener mechanisms — `addEventListener`
and `onchange`. Some platforms (older WebKit / mobile) support only one,
so the module attaches via `addEventListener` when available and falls
back to assigning `onchange` otherwise. The unsubscribe function clears
the corresponding mechanism for each subscription. The
`checkPermissions` helper normalises unknown state strings to `"prompt"`
so callers never have to defend against future spec values. Query
rejection is treated as `"prompt"` rather than `"denied"` because a
rejection typically signals "API not supported" rather than "user
explicitly refused" — pessimistic mapping would block correct callers in
older browsers. Tests cover both `addEventListener` and `onchange` paths,
plus the unsubscribe contract.

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
ClipboardNavigator,
PermissionResult,
PermissionStatusLike,
} from "../types/clipboard.types.js";
import { checkPermissions, onPermissionChange } from "./permissions.js";
interface MutableStatus {
state: string;
addEventListener?: (
type: "change",
cb: (this: PermissionStatusLike) => void
) => void;
removeEventListener?: (
type: "change",
cb: (this: PermissionStatusLike) => void
) => void;
onchange?: ((this: PermissionStatusLike) => void) | null;
}
function makeStatus(state: string): MutableStatus & {
fire: () => void;
listeners: Array<() => void>;
} {
const listeners: Array<() => void> = [];
return {
state,
listeners,
addEventListener(_type, cb) {
listeners.push(cb as () => void);
},
removeEventListener(_type, cb) {
const idx = listeners.indexOf(cb as () => void);
if (idx >= 0) {
listeners.splice(idx, 1);
}
},
fire() {
for (const l of listeners) {
l.call(this);
}
},
};
}
describe("checkPermissions", () => {
it("checkPermissions reflects granted and prompt states", {
timeout: 5000,
}, async () => {
const nav: ClipboardNavigator = {
permissions: {
query: async (d) => ({
state: d.name === "clipboard-read" ? "granted" : "prompt",
}),
},
};
const result = await checkPermissions(nav);
expect(result).toEqual({ read: "granted", write: "prompt" });
});
it("checkPermissions returns prompt defaults when API absent", {
timeout: 5000,
}, async () => {
const result = await checkPermissions({});
expect(result).toEqual({ read: "prompt", write: "prompt" });
});
it("checkPermissions normalises unknown states to prompt", {
timeout: 5000,
}, async () => {
const nav: ClipboardNavigator = {
permissions: {
query: async () => ({ state: "weird" }),
},
};
const result = await checkPermissions(nav);
expect(result).toEqual({ read: "prompt", write: "prompt" });
});
it("checkPermissions returns prompt for rejected queries", {
timeout: 5000,
}, async () => {
const nav: ClipboardNavigator = {
permissions: {
query: async () => {
throw new Error("nope");
},
},
};
const result = await checkPermissions(nav);
expect(result).toEqual({ read: "prompt", write: "prompt" });
});
});
describe("onPermissionChange", () => {
it("onPermissionChange delivers updates and unsubscribes cleanly", {
timeout: 5000,
}, async () => {
const readStatus = makeStatus("prompt");
const writeStatus = makeStatus("prompt");
const statuses: Record<string, MutableStatus> = {
"clipboard-read": readStatus,
"clipboard-write": writeStatus,
};
const nav: ClipboardNavigator = {
permissions: {
query: async (d) =>
statuses[d.name] as unknown as PermissionStatusLike,
},
};
const seen: Array<{ name: string; snapshot: PermissionResult }> = [];
const unsub = await onPermissionChange(nav, (snapshot, name) => {
seen.push({ snapshot, name });
});
readStatus.state = "granted";
readStatus.fire();
// The fire() call schedules an async checkPermissions; wait a tick.
await new Promise((r) => setTimeout(r, 5));
expect(seen.length).toBe(1);
expect(seen[0]?.name).toBe("clipboard-read");
expect(seen[0]?.snapshot.read).toBe("granted");
unsub();
readStatus.state = "denied";
readStatus.fire();
await new Promise((r) => setTimeout(r, 5));
expect(seen.length).toBe(1);
});
it("falls back to onchange property when addEventListener absent", {
timeout: 5000,
}, async () => {
let onchange: (() => void) | null = null;
const status: PermissionStatusLike & { state: string } = {
state: "prompt",
onchange: null,
};
Object.defineProperty(status, "onchange", {
get(): (() => void) | null {
return onchange;
},
set(v: (() => void) | null) {
onchange = v;
},
});
const nav: ClipboardNavigator = {
permissions: {
query: async () => status,
},
};
const events: string[] = [];
const unsub = await onPermissionChange(nav, (_s, name) => {
events.push(name);
});
expect(typeof onchange).toBe("function");
onchange?.();
await new Promise((r) => setTimeout(r, 5));
expect(events.length).toBeGreaterThanOrEqual(1);
unsub();
expect(onchange).toBe(null);
});
it("skips statuses whose query rejects", { timeout: 5000 }, async () => {
const nav: ClipboardNavigator = {
permissions: {
query: async () => {
throw new Error("nope");
},
},
};
const unsub = await onPermissionChange(nav, () => {});
expect(typeof unsub).toBe("function");
unsub();
});
it("returns no-op unsub when permissions API absent", {
timeout: 5000,
}, async () => {
const unsub = await onPermissionChange({}, () => {});
expect(typeof unsub).toBe("function");
unsub();
});
});

View File

@@ -0,0 +1,105 @@
import type {
ClipboardNavigator,
ClipboardPermissionState,
PermissionChangeListener,
PermissionResult,
PermissionStatusLike,
} from "../types/clipboard.types.js";
const STATES: ReadonlySet<ClipboardPermissionState> = new Set([
"granted",
"denied",
"prompt",
]);
function normalise(state: string | undefined): ClipboardPermissionState {
return STATES.has(state as ClipboardPermissionState)
? (state as ClipboardPermissionState)
: "prompt";
}
async function queryOne(
nav: ClipboardNavigator,
name: "clipboard-read" | "clipboard-write"
): Promise<PermissionStatusLike | undefined> {
if (nav.permissions === undefined) {
return undefined;
}
try {
return await nav.permissions.query({ name });
} catch {
return undefined;
}
}
/**
* Probe both `clipboard-read` and `clipboard-write` permissions. Resilient
* to missing API or query rejection — falls back to `prompt` for each slot
* without re-throwing.
*/
export async function checkPermissions(
nav: ClipboardNavigator
): Promise<PermissionResult> {
if (nav.permissions === undefined) {
return { read: "prompt", write: "prompt" };
}
const [readStatus, writeStatus] = await Promise.all([
queryOne(nav, "clipboard-read"),
queryOne(nav, "clipboard-write"),
]);
return {
read: normalise(readStatus?.state),
write: normalise(writeStatus?.state),
};
}
interface SubscriptionRecord {
readonly status: PermissionStatusLike;
readonly onChange: () => void;
}
/**
* Subscribe `cb` to permission state changes on both `clipboard-read` and
* `clipboard-write`. Returns an unsubscribe that detaches both listeners.
*
* The returned promise resolves with the unsubscribe function once the
* underlying `permissions.query` calls have settled — subscribers receive
* `cb` invocations only for subsequent state transitions.
*/
export async function onPermissionChange(
nav: ClipboardNavigator,
cb: PermissionChangeListener
): Promise<() => void> {
const records: SubscriptionRecord[] = [];
const names: ReadonlyArray<"clipboard-read" | "clipboard-write"> = [
"clipboard-read",
"clipboard-write",
];
for (const name of names) {
const status = await queryOne(nav, name);
if (status === undefined) {
continue;
}
const onChange = (): void => {
void checkPermissions(nav).then((snapshot) => {
cb(snapshot, name);
});
};
if (status.addEventListener !== undefined) {
status.addEventListener("change", onChange);
} else {
status.onchange = onChange;
}
records.push({ status, onChange });
}
return (): void => {
for (const { status, onChange } of records) {
if (status.removeEventListener !== undefined) {
status.removeEventListener("change", onChange);
}
if (status.onchange === onChange) {
status.onchange = null;
}
}
};
}

View File

@@ -0,0 +1 @@
export { AsyncQueue } from "./queue.js";

View File

@@ -0,0 +1,96 @@
# `queue/queue.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AsyncQueue` is a minimal FIFO asynchronous queue that serializes
operations on a single tail promise. The `ClipboardManager` uses it to
ensure concurrent `copyText` / `readText` / `copyRichContent` /
`readRichContent` calls execute in arrival order — without it, the
trailing call would win non-deterministically because the browser's
Clipboard API does not provide ordering guarantees across overlapping
requests. The queue is intentionally tiny (one promise reference plus a
drained flag) so it adds no measurable overhead in the common single-call
case.
## Features
- FIFO ordering — callbacks run strictly in arrival order.
- Rejection-tolerant — a rejected operation does not stall later ones.
- `drain()` closes the queue and resolves once the tail settles.
- `reset()` re-opens a drained queue (useful for tests + dispose flows).
- Zero dependencies; no `Promise.race`, no timers, no microtask leaks.
## Usage: Basic
```ts
import { AsyncQueue } from "@suaveplan/clipboard";
const queue = new AsyncQueue();
const results = await Promise.all([
queue.enqueue(async () => "a"),
queue.enqueue(async () => "b"),
queue.enqueue(async () => "c"),
]);
// results === ["a", "b", "c"]
```
## Usage: Advanced
```ts
import { AsyncQueue } from "@suaveplan/clipboard";
const queue = new AsyncQueue();
// Rejection does not break the queue.
queue.enqueue(async () => {
throw new Error("boom");
}).catch(() => undefined);
const next = await queue.enqueue(async () => "still works");
// Close the queue when shutting down.
await queue.drain();
// Re-open later for a new session.
queue.reset();
await queue.enqueue(async () => "round two");
```
## API Reference
### `new AsyncQueue()`
Constructs a queue with an already-resolved tail.
### `queue.enqueue<T>(fn: () => Promise<T>): Promise<T>`
Append `fn` to the queue. The returned promise settles with `fn`'s
outcome once every previously enqueued callback has settled. If the
queue has been drained, returns a rejected promise carrying `"AsyncQueue:
enqueue after drain"`.
### `queue.drain(): Promise<void>`
Closes the queue. Subsequent `enqueue` calls reject. Returns a promise
that resolves once the current tail settles (success or failure).
### `queue.reset(): void`
Re-opens a drained queue. Replaces the tail with a fresh
`Promise.resolve()`.
## Implementation Notes
The tail promise chains through `then(onFulfilled, onRejected)` with both
arms re-invoking `fn`. This means an operation always runs after the
previous one regardless of how the previous resolved — the rejection does
not propagate into the queue's tracked tail (we explicitly catch via
`.then(() => undefined, () => undefined)`). Because the tail is `unknown`-
typed internally, the generic `T` parameter on `enqueue` is preserved end
to end. The drained flag is checked synchronously inside `enqueue` so
post-drain calls reject without scheduling work. The queue is private to
the manager but exported so advanced consumers can reuse it for
caller-defined ordering (e.g. when composing with other browser APIs).

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AsyncQueue } from "./queue.js";
describe("AsyncQueue", () => {
it("enqueue preserves arrival order", { timeout: 5000 }, async () => {
const q = new AsyncQueue();
const order: string[] = [];
const results = await Promise.all([
q.enqueue(async () => {
await new Promise<void>((r) => setTimeout(r, 30));
order.push("A");
return "a";
}),
q.enqueue(async () => {
await new Promise<void>((r) => setTimeout(r, 5));
order.push("B");
return "b";
}),
q.enqueue(async () => {
order.push("C");
return "c";
}),
]);
expect(order).toEqual(["A", "B", "C"]);
expect(results).toEqual(["a", "b", "c"]);
});
it("rejection does not stall later operations", {
timeout: 5000,
}, async () => {
const q = new AsyncQueue();
const order: string[] = [];
const p1 = q
.enqueue(async () => {
order.push("first");
throw new Error("boom");
})
.catch((err: unknown) => err);
const p2 = q.enqueue(async () => {
order.push("second");
return 42;
});
const [r1, r2] = await Promise.all([p1, p2]);
expect((r1 as Error).message).toBe("boom");
expect(r2).toBe(42);
expect(order).toEqual(["first", "second"]);
});
it("drain awaits current tail", { timeout: 5000 }, async () => {
const q = new AsyncQueue();
let done = false;
q.enqueue(async () => {
await new Promise<void>((r) => setTimeout(r, 20));
done = true;
});
await q.drain();
expect(done).toBe(true);
});
it("enqueue after drain rejects", { timeout: 5000 }, async () => {
const q = new AsyncQueue();
await q.drain();
await expect(q.enqueue(async () => 1)).rejects.toThrow(
/enqueue after drain/
);
});
it("reset re-opens a drained queue", { timeout: 5000 }, async () => {
const q = new AsyncQueue();
await q.drain();
q.reset();
await expect(q.enqueue(async () => "ok")).resolves.toBe("ok");
});
});

View File

@@ -0,0 +1,44 @@
/**
* Minimal asynchronous FIFO queue. Every `enqueue(fn)` chains to the
* previously enqueued operation so callbacks execute in arrival order.
* Rejections do not stall the queue — the next operation runs regardless
* of the previous result.
*/
export class AsyncQueue {
private tail: Promise<unknown> = Promise.resolve();
private drained = false;
/**
* Append `fn` to the queue. Returns a promise that settles with `fn`'s
* outcome once every previously enqueued callback has settled.
*/
enqueue<T>(fn: () => Promise<T>): Promise<T> {
if (this.drained) {
return Promise.reject(new Error("AsyncQueue: enqueue after drain"));
}
// Tail is always a settled-or-resolved promise (we catch every
// arm into `undefined` below), so a single `.then(onFulfilled)` is
// sufficient — no rejection arm is reachable.
const run = this.tail.then(() => fn());
this.tail = run.then(
() => undefined,
() => undefined
);
return run;
}
/**
* Resolve once the current tail settles. Subsequent `enqueue` calls
* after `drain()` reject — the queue is considered closed.
*/
drain(): Promise<void> {
this.drained = true;
return this.tail.then(() => undefined);
}
/** Re-open a previously drained queue. Intended for tests + dispose flows. */
reset(): void {
this.drained = false;
this.tail = Promise.resolve();
}
}

View File

@@ -0,0 +1,4 @@
export {
createTagAllowlistSanitizer,
identitySanitizer,
} from "./sanitizer.js";

View File

@@ -0,0 +1,88 @@
# `sanitizer/sanitizer.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Sanitizer hooks applied to inbound `text/html` clipboard payloads. The
package ships an identity sanitizer (pass-through) plus a regex-based
tag-allowlist sanitizer for stripping known-dangerous tags from
untrusted clipboard HTML. Both adhere to the `ClipboardSanitizer` shape
`(html: string) => string` so consumers can plug in DOMPurify, sanitize-html,
or any other library without changing call sites. The package
deliberately does not bundle a security-grade sanitizer to avoid pinning
consumers to a specific implementation — `createTagAllowlistSanitizer` is
suitable for hardening trusted-origin clipboard flows and is NOT a
substitute for DOMPurify in security-critical paths.
## Features
- `identitySanitizer` — pass-through, used when no sanitizer is supplied.
- `createTagAllowlistSanitizer(tags)` — regex-based stripper that drops
any tag not in the supplied allowlist while preserving allowed tags
with their attributes intact.
- Case-insensitive tag matching.
## Usage: Basic
```ts
import {
ClipboardManager,
createTagAllowlistSanitizer,
} from "@suaveplan/clipboard";
const sanitize = createTagAllowlistSanitizer(["p", "b", "i", "br"]);
const clipboard = new ClipboardManager({ sanitizer: sanitize });
const result = await clipboard.readRichContent();
// result.data.html no longer contains <script> or <iframe>.
```
## Usage: Advanced
```ts
import DOMPurify from "dompurify";
import { ClipboardManager } from "@suaveplan/clipboard";
const clipboard = new ClipboardManager({
sanitizer: (html) =>
DOMPurify.sanitize(html, { ALLOWED_TAGS: ["p", "a", "b", "i"] }),
});
const result = await clipboard.readRichContent();
if (result.success) {
const cleaned = result.data?.html ?? "";
// Pass `cleaned` to a downstream React component or DOM renderer
// that knows how to safely interpolate sanitized HTML.
return cleaned;
}
```
## API Reference
### `identitySanitizer(html: string): string`
Pass-through; returns input unchanged. Used internally as the default
sanitizer when no override is supplied.
### `createTagAllowlistSanitizer(allowed: readonly string[]): ClipboardSanitizer`
Returns a sanitizer that strips every HTML tag not present in `allowed`.
Tag matching is case-insensitive; opening and closing tags are stripped
together. Attributes on allowed tags are preserved. The function only
removes the angle-bracketed tag itself — any content nested inside
disallowed tags is left in place. For full DOM-level sanitization use a
real library such as DOMPurify.
## Implementation Notes
The regex used is intentionally conservative — it matches
`<[a-zA-Z][a-zA-Z0-9-]*` to capture tag names and tolerates optional
attribute payloads via `(\s[^>]*)?`. It does not attempt to handle CDATA,
comments, or processing instructions. This matches the documented scope:
trusted-origin clipboard hardening, not arbitrary-source HTML
sanitization. The `Set` of lowercased allowed tags keeps lookup at O(1).
The module is browser-tier but has no DOM dependency, so it also works
inside service workers and Node-side SSR consumers that happen to
process clipboard payloads on the server.

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createTagAllowlistSanitizer, identitySanitizer } from "./sanitizer.js";
describe("identitySanitizer", () => {
it("returns input untouched", { timeout: 5000 }, () => {
expect(identitySanitizer("<p>x</p>")).toBe("<p>x</p>");
});
});
describe("createTagAllowlistSanitizer", () => {
it("strips disallowed tags", { timeout: 5000 }, () => {
const sanitize = createTagAllowlistSanitizer(["p", "b"]);
expect(sanitize("<p>ok</p><script>alert(1)</script><b>bold</b>")).toBe(
"<p>ok</p>alert(1)<b>bold</b>"
);
});
it("matches case-insensitively", { timeout: 5000 }, () => {
const sanitize = createTagAllowlistSanitizer(["P"]);
expect(sanitize("<p>ok</p><SCRIPT>x</SCRIPT>")).toBe("<p>ok</p>x");
});
it("preserves attributes on allowed tags", { timeout: 5000 }, () => {
const sanitize = createTagAllowlistSanitizer(["a"]);
expect(sanitize('<a href="x">go</a>')).toBe('<a href="x">go</a>');
});
it("strips closing tags of disallowed elements", {
timeout: 5000,
}, () => {
const sanitize = createTagAllowlistSanitizer(["p"]);
expect(sanitize("<p>ok</p><div>x</div>")).toBe("<p>ok</p>x");
});
});

View File

@@ -0,0 +1,26 @@
import type { ClipboardSanitizer } from "../types/clipboard.types.js";
/**
* Default sanitizer — pass-through. Consumers MUST supply a real sanitizer
* (e.g. DOMPurify) when handling untrusted clipboard HTML; the package
* does not bundle a sanitizer to avoid pinning consumers to a specific
* implementation.
*/
export const identitySanitizer: ClipboardSanitizer = (html: string): string =>
html;
/**
* Build a conservative tag-allowlist sanitizer. Strips every tag not in the
* allowed set using a single regex pass — suitable for stripping `<script>`
* blocks from arbitrary clipboard HTML. NOT a substitute for a real
* HTML sanitizer in security-critical paths.
*/
export function createTagAllowlistSanitizer(
allowed: readonly string[]
): ClipboardSanitizer {
const set = new Set(allowed.map((tag) => tag.toLowerCase()));
return (html: string): string =>
html.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^>]*)?>/g, (match, tag) =>
set.has(String(tag).toLowerCase()) ? match : ""
);
}

View File

@@ -0,0 +1,5 @@
export {
isSecure,
requireSecureContext,
waitForNextFocus,
} from "./security.js";

View File

@@ -0,0 +1,105 @@
# `security/security.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Browser-side security helpers that guard clipboard operations against
insecure contexts and orchestrate the focus-aware retry path. The
Clipboard API is gated by the browser on `window.isSecureContext` — calls
from non-HTTPS origins reject with `NotAllowedError`, and the spec
explicitly forbids the API outside secure contexts. This module exposes a
typed `requireSecureContext` guard plus an SSR-safe `isSecure` predicate
so call sites surface `CLIPBOARD.INSECURE_CONTEXT` rather than a generic
DOMException. The companion `waitForNextFocus` helper resolves on the next
`focus` event (with a timeout) so the manager can recover from
`NotAllowedError: Document is not focused`.
## Features
- `isSecure(window?)` — SSR-safe predicate. Returns `true` outside the
browser so universal code paths short-circuit cleanly.
- `requireSecureContext(window?)` — throws
`ClipboardError(CLIPBOARD.INSECURE_CONTEXT)` when the window flags
`isSecureContext === false`.
- `waitForNextFocus(window?, timeoutMs)` — resolves `true` on the next
`focus` event, `false` on timeout, `false` when the window cannot
attach listeners.
## Usage: Basic
```ts
import { requireSecureContext } from "@suaveplan/clipboard";
requireSecureContext(); // throws CLIPBOARD.INSECURE_CONTEXT on HTTP
```
## Usage: Advanced
```ts
import {
CLIPBOARD_CODES,
ClipboardError,
isSecure,
waitForNextFocus,
} from "@suaveplan/clipboard";
async function copyWithFocusRetry(text: string): Promise<void> {
if (!isSecure()) {
throw new ClipboardError(
CLIPBOARD_CODES.INSECURE_CONTEXT,
"Refusing clipboard write outside HTTPS.",
);
}
try {
await navigator.clipboard.writeText(text);
} catch (err) {
if (
err instanceof Error &&
err.name === "NotAllowedError" &&
err.message.includes("focus")
) {
const focused = await waitForNextFocus(window, 500);
if (focused) {
await navigator.clipboard.writeText(text);
return;
}
}
throw err;
}
}
```
## API Reference
### `isSecure(window?: ClipboardWindowLike): boolean`
Returns `true` when the supplied window does not declare
`isSecureContext: false`. Returns `true` when no window is supplied and
no global `window` exists (SSR safe).
### `requireSecureContext(window?: ClipboardWindowLike): void`
Throws `ClipboardError(CLIPBOARD.INSECURE_CONTEXT)` when `isSecure`
returns false. No-op otherwise.
### `waitForNextFocus(window?: ClipboardWindowLike, timeoutMs: number): Promise<boolean>`
Subscribes to the next `focus` event on the supplied window (or the
global) and resolves `true` when the event fires. Resolves `false` if the
window does not expose `addEventListener` or if the timeout elapses
first. Internally guards against duplicate resolves with a `settled`
flag.
## Implementation Notes
The module accepts a `ClipboardWindowLike` shape rather than the DOM `Window`
type so tests + SSR consumers can inject minimal shims. The focus listener
is registered with `{ once: true }` so it removes itself on first
delivery; the timeout path additionally calls `removeEventListener`
defensively in case the host environment ignores `{ once: true }`. The
`settled` flag prevents the late-delivered focus event from resolving the
promise after the timeout has already fired — a real-world race that the
unit tests assert on.

View File

@@ -0,0 +1,158 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES, ClipboardError } from "../errors/errors.js";
import {
isSecure,
requireSecureContext,
waitForNextFocus,
} from "./security.js";
describe("isSecure", () => {
it("treats undefined window as secure (SSR-safe)", {
timeout: 5000,
}, () => {
expect(isSecure(undefined)).toBe(true);
});
it("returns true when window.isSecureContext is true", {
timeout: 5000,
}, () => {
expect(isSecure({ isSecureContext: true })).toBe(true);
});
it("returns false when window.isSecureContext is false", {
timeout: 5000,
}, () => {
expect(isSecure({ isSecureContext: false })).toBe(false);
});
it("falls back to global window when none supplied", {
timeout: 5000,
}, () => {
// happy-dom global window is a secure context by default
expect(isSecure()).toBe(true);
});
});
describe("requireSecureContext", () => {
it("requireSecureContext throws INSECURE_CONTEXT when window flags insecure", {
timeout: 5000,
}, () => {
let thrown: unknown;
try {
requireSecureContext({ isSecureContext: false });
} catch (err) {
thrown = err;
}
expect(thrown).toBeInstanceOf(ClipboardError);
expect((thrown as ClipboardError).code).toBe(
CLIPBOARD_CODES.INSECURE_CONTEXT
);
});
it("requireSecureContext returns silently for secure context", {
timeout: 5000,
}, () => {
expect(() =>
requireSecureContext({ isSecureContext: true })
).not.toThrow();
});
});
describe("waitForNextFocus", () => {
it("resolves false when window has no addEventListener", {
timeout: 5000,
}, async () => {
const resolved = await waitForNextFocus({}, 50);
expect(resolved).toBe(false);
});
it("resolves true when focus event fires before timeout", {
timeout: 5000,
}, async () => {
const listeners = new Map<string, () => void>();
const win = {
addEventListener: (type: string, cb: () => void) => {
listeners.set(type, cb);
},
removeEventListener: (type: string) => {
listeners.delete(type);
},
};
const promise = waitForNextFocus(win, 200);
setTimeout(() => {
listeners.get("focus")?.();
}, 10);
const resolved = await promise;
expect(resolved).toBe(true);
});
it("resolves false on timeout", { timeout: 5000 }, async () => {
const listeners = new Map<string, () => void>();
const win = {
addEventListener: (type: string, cb: () => void) => {
listeners.set(type, cb);
},
removeEventListener: (type: string) => {
listeners.delete(type);
},
};
const resolved = await waitForNextFocus(win, 30);
expect(resolved).toBe(false);
});
it("ignores late focus after timeout fired", {
timeout: 5000,
}, async () => {
const focusListeners: Array<() => void> = [];
const win = {
addEventListener: (_type: string, cb: () => void) => {
focusListeners.push(cb);
},
removeEventListener: () => {
// Keep listener registered so we can fire it post-timeout to
// exercise the settled-guard branch in waitForNextFocus.
},
};
const result = await waitForNextFocus(win, 20);
expect(result).toBe(false);
// Now fire focus AFTER timeout — settled flag must suppress resolve.
for (const cb of focusListeners) {
cb();
}
});
it("timeout firing after focus is a no-op", {
timeout: 5000,
}, async () => {
const focusListeners: Array<() => void> = [];
const win = {
addEventListener: (_type: string, cb: () => void) => {
focusListeners.push(cb);
},
removeEventListener: () => {},
};
const promise = waitForNextFocus(win, 30);
setTimeout(() => focusListeners[0]?.(), 5);
const resolved = await promise;
expect(resolved).toBe(true);
// Allow the 30ms timeout to fire — it must noop because settled.
await new Promise<void>((r) => setTimeout(r, 50));
});
it("ignores duplicate focus events", {
timeout: 5000,
}, async () => {
const focusListeners: Array<() => void> = [];
const win = {
addEventListener: (_type: string, cb: () => void) => {
focusListeners.push(cb);
},
removeEventListener: () => {},
};
const promise = waitForNextFocus(win, 200);
focusListeners[0]?.();
focusListeners[0]?.();
const resolved = await promise;
expect(resolved).toBe(true);
});
});

View File

@@ -0,0 +1,81 @@
import { CLIPBOARD_CODES, ClipboardError } from "../errors/errors.js";
import type { ClipboardWindowLike } from "../types/clipboard.types.js";
/**
* Resolve the effective window object. When no explicit window is provided
* and a global `window` exists, use the global; otherwise return
* `undefined`. SSR environments simply receive `undefined`.
*/
function resolveWindow(
win?: ClipboardWindowLike
): ClipboardWindowLike | undefined {
if (win !== undefined) {
return win;
}
if (typeof window !== "undefined") {
return window as unknown as ClipboardWindowLike;
}
return undefined;
}
/**
* Return whether the host window is a secure context. SSR environments
* (`window` undefined) return `true` because they cannot make user-driven
* clipboard calls anyway — the SSR-safe path returns
* `CLIPBOARD.DOCUMENT_UNAVAILABLE` from the manager.
*/
export function isSecure(win?: ClipboardWindowLike): boolean {
const resolved = resolveWindow(win);
if (resolved === undefined) {
return true;
}
return resolved.isSecureContext !== false;
}
/**
* Throw {@link ClipboardError} with code `CLIPBOARD.INSECURE_CONTEXT` when
* the host window reports `isSecureContext === false`. No-op when the
* window is secure or absent.
*/
export function requireSecureContext(win?: ClipboardWindowLike): void {
if (!isSecure(win)) {
throw new ClipboardError(
CLIPBOARD_CODES.INSECURE_CONTEXT,
"Clipboard operations require a secure context (HTTPS or localhost)."
);
}
}
/**
* Wait for the next `focus` event on the window then resolve. Used by the
* focus-aware retry path. Resolves immediately when the window cannot
* supply `addEventListener` (SSR / minimal shim).
*/
export function waitForNextFocus(
win: ClipboardWindowLike | undefined,
timeoutMs: number
): Promise<boolean> {
const resolved = resolveWindow(win);
if (resolved?.addEventListener === undefined) {
return Promise.resolve(false);
}
return new Promise<boolean>((resolve) => {
let settled = false;
const onFocus = (): void => {
if (settled) {
return;
}
settled = true;
resolve(true);
};
resolved.addEventListener?.("focus", onFocus, { once: true });
setTimeout(() => {
if (settled) {
return;
}
settled = true;
resolved.removeEventListener?.("focus", onFocus);
resolve(false);
}, timeoutMs);
});
}

View File

@@ -0,0 +1,96 @@
# `types/clipboard.types.ts`
## Purpose
Shared type definitions for `@suaveplan/clipboard`. Houses every
public-surface interface, type alias, and the `DEFAULT_CLIPBOARD_OPTIONS`
runtime constant. Splitting types into a flat `types/` directory keeps
the implementation modules focused on behaviour while every consumer can
import a single canonical shape — preventing the historical drift where
`ClipboardResult` ended up declared three times across the codebase.
## Features
- `ClipboardPermissionState` — `"granted" | "denied" | "prompt"`.
- `ClipboardBlobMap` — readonly MIME → Blob map for arbitrary binary
clipboard payloads.
- `ClipboardSanitizer` — `(html: string) => string` hook applied to
inbound rich HTML.
- `PermissionChangeListener` — callback shape for
`ClipboardManager.onPermissionChange`.
- `ClipboardData` — `{ text?, html?, image?, blobs? }`.
- `ClipboardOptions` — full options bag accepted by `ClipboardManager`.
- `ClipboardResult<T>` — `{ success, data?, error?, code?, method }`.
- `PermissionResult` — `{ read, write }` snapshot.
- `PermissionStatusLike` — structural view of `PermissionStatus`.
- `ClipboardNavigator` — structural view of `navigator.clipboard` +
`navigator.permissions`.
- `ClipboardWindowLike` — structural view of `window` for
secure-context detection and focus-retry orchestration.
- `DEFAULT_CLIPBOARD_OPTIONS` — frozen options default.
## Usage: Basic
```ts
import {
DEFAULT_CLIPBOARD_OPTIONS,
type ClipboardData,
type ClipboardOptions,
} from "@suaveplan/clipboard";
const opts: Partial<ClipboardOptions> = {
...DEFAULT_CLIPBOARD_OPTIONS,
useFallback: false,
};
const data: ClipboardData = {
text: "hello",
html: "<b>hi</b>",
};
```
## Usage: Advanced
```ts
import type {
ClipboardNavigator,
ClipboardResult,
ClipboardWindowLike,
PermissionChangeListener,
} from "@suaveplan/clipboard";
// Compose a server-side shim used by integration tests:
const ssrNav: ClipboardNavigator = {};
const ssrWin: ClipboardWindowLike = { isSecureContext: true };
function describe(result: ClipboardResult<string>): string {
return result.success
? `[${result.method}] copied "${result.data}"`
: `[${result.method}] error ${result.code}: ${result.error}`;
}
const listener: PermissionChangeListener = (snapshot, name) => {
console.info("change", name, snapshot);
};
```
## API Reference
Every exported name is a TypeScript interface or type alias — see the
listing in **Features**. Runtime exports: `DEFAULT_CLIPBOARD_OPTIONS`
(frozen `ClipboardOptions` with `useFallback: true`,
`requireSecureContext: false`, `serialize: true`, `focusRetry: true`,
`sanitizer: undefined`, `pasteTimeoutMs: 1500`, `permissionTimeout: 5000`,
`observability: undefined`).
## Implementation Notes
The structural-view interfaces (`ClipboardNavigator`,
`ClipboardWindowLike`, `PermissionStatusLike`) are intentionally minimal
— they expose only the fields the package actually reads. This keeps the
DOM-typing surface narrow enough to substitute without depending on
`@types/dom`, and lets the package run under SSR shims that don't carry
a full DOM type pack. The `Readonly` modifiers across every option and
result field communicate that the API never mutates caller-supplied data;
the freeze on `DEFAULT_CLIPBOARD_OPTIONS` enforces that at runtime so
shared references can't be accidentally mutated.

View File

@@ -0,0 +1,149 @@
import type { ClipboardObservability } from "../observability/observability.types.js";
/**
* Permission state — mirrors the lowercase `PermissionState` reported by the
* Permissions API. The package retains the `prompt` default for environments
* without the API.
*/
export type ClipboardPermissionState = "granted" | "denied" | "prompt";
/**
* MIME-keyed map for arbitrary binary payloads (anything other than
* `text/plain`, `text/html`, and `image/*`).
*/
export type ClipboardBlobMap = Readonly<Record<string, Blob>>;
/**
* Sanitizer hook applied to inbound `text/html` payloads.
*/
export type ClipboardSanitizer = (html: string) => string;
/**
* Listener for permission state transitions. Receives the latest
* {@link PermissionResult} snapshot and the descriptor name whose state
* changed.
*/
export type PermissionChangeListener = (
snapshot: PermissionResult,
name: "clipboard-read" | "clipboard-write"
) => void;
/**
* Structured clipboard payload for rich content. Every field is optional —
* callers populate the subset they have.
*/
export interface ClipboardData {
/**Plain text payload (`text/plain`). */
readonly text?: string;
/** Raw HTML payload (`text/html`). Subject to the manager's sanitizer on read. */
readonly html?: string;
/**Binary image payload (any `image/*` MIME). */
readonly image?: Blob;
/** MIME-keyed map for arbitrary binary payloads. */
readonly blobs?: ClipboardBlobMap;
}
/**
* Options bag accepted by {@link ClipboardManager}. Every field has a default
* from {@link DEFAULT_CLIPBOARD_OPTIONS} so callers only override what they
* need.
*/
export interface ClipboardOptions {
/**Use the `<textarea>` + `execCommand("copy")` fallback when the API is unavailable or rejects. */
readonly useFallback: boolean;
/** Probe `window.isSecureContext` and short-circuit copies in insecure contexts. */
readonly requireSecureContext: boolean;
/**Serialize copy/read calls through an internal AsyncQueue. */
readonly serialize: boolean;
/** Retry once after the next `focus` event when the API rejects with `NotAllowedError`. */
readonly focusRetry: boolean;
/**Optional HTML sanitizer applied to inbound rich payloads. */
readonly sanitizer: ClipboardSanitizer | undefined;
/** Paste-event fallback timeout in milliseconds. */
readonly pasteTimeoutMs: number;
/**Permission query timeout — preserved from upstream for API compatibility. */
readonly permissionTimeout: number;
/** Optional observability bundle to inject for metrics + traces. */
readonly observability: ClipboardObservability | undefined;
}
/**
* Result of any clipboard operation. `code` carries a namespaced
* `CLIPBOARD.*` error code on failure; consumers should match on `code`
* rather than parsing `error` strings.
*/
export interface ClipboardResult<T = string> {
readonly success: boolean;
readonly data?: T;
readonly error?: string;
readonly code?: string;
readonly method: "api" | "fallback";
}
/** Result of {@link ClipboardManager.checkPermissions}. */
export interface PermissionResult {
readonly read: ClipboardPermissionState;
readonly write: ClipboardPermissionState;
}
/**
* Structural view of the Permissions API surface we use. Allows tests and
* SSR shims to substitute a real-shaped object without depending on a global
* `PermissionStatus` constructor.
*/
export interface PermissionStatusLike {
readonly state: string;
addEventListener?: (
type: "change",
cb: (this: PermissionStatusLike) => void
) => void;
removeEventListener?: (
type: "change",
cb: (this: PermissionStatusLike) => void
) => void;
onchange?: ((this: PermissionStatusLike) => void) | null;
}
/**
* Structural view of `navigator.clipboard` + `navigator.permissions` used
* by the manager. Tests inject this shape directly; production code uses
* the default global `navigator`.
*/
export interface ClipboardNavigator {
clipboard?: {
writeText(text: string): Promise<void>;
readText(): Promise<string>;
write(items: ClipboardItem[]): Promise<void>;
read(): Promise<ClipboardItem[]>;
};
permissions?: {
query(descriptor: { name: string }): Promise<PermissionStatusLike>;
};
}
/** Structural view of the host window used for secure-context detection. */
export interface ClipboardWindowLike {
readonly isSecureContext?: boolean;
addEventListener?: (
type: "focus",
cb: () => void,
opts?: { once?: boolean }
) => void;
removeEventListener?: (type: "focus", cb: () => void) => void;
}
/**
* Default {@link ClipboardOptions}. Extended fields default to enterprise-safe
* values (serialization enabled, focus-retry enabled, paste timeout 1500ms,
* no sanitizer, no required secure context, no observability).
*/
export const DEFAULT_CLIPBOARD_OPTIONS: ClipboardOptions = Object.freeze({
useFallback: true,
requireSecureContext: false,
serialize: true,
focusRetry: true,
sanitizer: undefined,
pasteTimeoutMs: 1500,
permissionTimeout: 5000,
observability: undefined,
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,52 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/clipboard",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"manager/manager": "src/manager/manager.ts",
"fallback/fallback": "src/fallback/fallback.ts",
"permissions/permissions": "src/permissions/permissions.ts",
"queue/queue": "src/queue/queue.ts",
"observability/observability":
"src/observability/observability.ts",
"errors/errors": "src/errors/errors.ts",
"security/security": "src/security/security.ts",
"sanitizer/sanitizer": "src/sanitizer/sanitizer.ts",
"types/clipboard.types": "src/types/clipboard.types.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/opentelemetry",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,7 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
environment: "happy-dom",
fileParallelism: false,
testTimeout: 5000,
});

View File

@@ -0,0 +1,128 @@
# @suaveplan/data-masking - PII/sensitive data masking for objects and strings: field-level masking rules, nested object traversal, format-preserving masking, tokenization, and FNV-1a hashing.
## Summary
PII/sensitive data masking for objects and strings with field-level rules,
nested traversal, eight masking strategies, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-masker](./src/core/masker/object-masker.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [data-masking-error](./src/errors/data-masking-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mask-rule.schema](./src/schemas/mask-rule.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [credit-card](./src/strategies/credit-card.md) — The `credit-card` masking strategy produces the PCI-DSS-compatible truncation format for payment card numbers: last 4 d…
- [email](./src/strategies/email.md) — The `email` masking strategy anonymises email addresses while keeping the output immediately recognisable as an email a…
- [full](./src/strategies/full.md) — The `full` masking strategy completely replaces an input string with a configurable replacement sentinel. It is the mos…
- [hash](./src/strategies/hash.md) — The `hash` masking strategy produces a deterministic, fixed-length 8-character hexadecimal token from any input string…
- [partial](./src/strategies/partial.md) — The `partial` masking strategy preserves a configurable number of characters at the start and end of an input string wh…
- [phone](./src/strategies/phone.md) — The `phone` masking strategy anonymises phone numbers while preserving their formatting separators, making masked value…
- [preserve-format](./src/strategies/preserve-format.md) — The `preserve-format` masking strategy replaces every alphanumeric character with a placeholder of the same character c…
- [tokenize](./src/strategies/tokenize.md) — The `tokenize` masking strategy replaces a sensitive value with a structured opaque token of the form `TOKEN_<STRATEGY>…
- [types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mask-types](./src/types/mask-types.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_ - [path-matcher](./src/utils/path-matcher.md) — <!-- begin:auto-docs --> _(no submodules)_
## Installation
```bash
bun add @suaveplan/data-masking
```
## Quick Start
```ts
import { maskObject } from '@suaveplan/data-masking';
const result = maskObject(
{
user: { name: 'John Doe', email: 'john@example.com', ssn: '123-45-6789' },
payment: { cardNumber: '4111-1111-1111-1111', cvv: '123' },
},
{
rules: [
{ fields: ['user.email'], strategy: 'email' },
{ fields: ['user.ssn', 'payment.cvv'], strategy: 'full' },
{ fields: ['payment.cardNumber'], strategy: 'credit-card' },
],
},
);
console.log(result.masked.user.email); // 'j***@e***.com'
console.log(result.masked.payment.cardNumber); // '****-****-****-1111'
console.log(result.fieldsAffected); // ['payment.cardNumber', 'payment.cvv', 'user.email', 'user.ssn']
```
## Strategies
| Strategy | Description | Example output |
|-------------------|------------------------------------------|-------------------------|
| `full` | Replace entire value with `***` | `***` |
| `partial` | Keep first/last N chars, mask middle | `sk_***...****789` |
| `email` | Format-preserving email mask | `j***@e***.com` |
| `phone` | Keep last 4 digits | `***-***-1234` |
| `credit-card` | Keep last 4 digits | `****-****-****-1234` |
| `hash` | FNV-1a 32-bit hex (deterministic) | `4f9f2cab` |
| `tokenize` | Opaque token (not reversible) | `TOKEN_TOKENIZE_0` |
| `preserve-format` | Replace alnum with type placeholder | `XXXXX000` |
## Field Path Patterns
Rules use dot-notation paths with optional glob wildcards:
```ts
{ fields: ['user.email'] } // exact match
{ fields: ['user.*'] } // single-segment wildcard
{ fields: ['user.**'] } // multi-segment wildcard (any depth)
{ fields: ['**.email'] } // any path ending in .email
```
## Modules
| Subpath | Contents |
|---------------------|--------------------------------------------|
| `@suaveplan/data-masking` | Main exports (maskObject, maskString, maskValue, types, schemas) |
| `@suaveplan/data-masking/masker` | [object-masker](src/core/masker/object-masker.md) — core traversal engine |
| `@suaveplan/data-masking/strategies` | [full](src/strategies/full.md), [partial](src/strategies/partial.md), [email](src/strategies/email.md), [phone](src/strategies/phone.md), [credit-card](src/strategies/credit-card.md), [hash](src/strategies/hash.md), [tokenize](src/strategies/tokenize.md), [preserve-format](src/strategies/preserve-format.md) |
| `@suaveplan/data-masking/errors` | [DataMaskingError](src/errors/data-masking-error.md), `DATA_MASKING_CODES` |
| `@suaveplan/data-masking/schemas` | Zod schemas for `MaskRule`, `MaskOptions` |
## License
MIT

View File

@@ -0,0 +1,100 @@
{
"name": "@suaveplan/data-masking",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "PII/sensitive data masking for objects and strings: field-level masking rules, nested object traversal, format-preserving masking, tokenization, and FNV-1a hashing.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./masker": {
"import": {
"types": "./dist/core/masker/object-masker.d.ts",
"default": "./dist/core/masker/object-masker.js"
},
"require": {
"types": "./dist/core/masker/object-masker.d.ts",
"default": "./dist/core/masker/object-masker.js"
}
},
"./strategies": {
"import": {
"types": "./dist/strategies/index.d.ts",
"default": "./dist/strategies/index.js"
},
"require": {
"types": "./dist/strategies/index.d.ts",
"default": "./dist/strategies/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/data-masking-error.d.ts",
"default": "./dist/errors/data-masking-error.js"
},
"require": {
"types": "./dist/errors/data-masking-error.d.ts",
"default": "./dist/errors/data-masking-error.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/mask-rule.schema.d.ts",
"default": "./dist/schemas/mask-rule.schema.js"
},
"require": {
"types": "./dist/schemas/mask-rule.schema.d.ts",
"default": "./dist/schemas/mask-rule.schema.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1 @@
export { maskObject, maskString, maskValue } from "./masker";

View File

@@ -0,0 +1 @@
export { maskObject, maskString, maskValue } from "./object-masker";

View File

@@ -0,0 +1,128 @@
# object-masker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `object-masker` module is the core engine of `@suaveplan/data-masking`. It
provides three exports: `maskValue` for dispatching a masking strategy to a
single string, `maskObject` for traversing and masking a nested plain-object
graph, and `maskString` for detecting and masking embedded PII patterns inside
free-form text. All traversal is non-destructive — the input is never mutated;
a deep structural clone is produced instead.
OpenTelemetry instrumentation records a counter per strategy (`data_masking.mask.total`)
and a histogram of overall `maskObject` call duration (`data_masking.mask.duration_ms`),
enabling SREs to monitor masking throughput and latency in production.
## Features
- `maskValue`: uniform dispatch to all eight masking strategies.
- `maskObject`: deep-clone traversal with `deep` and `maskArrays` controls.
- `maskString`: regex-based PII detection for email, phone, and credit-card
patterns embedded in free-form strings.
- Non-mutating: original object/string always preserved.
- OTel counter + histogram on every operation.
- Sorted `fieldsAffected` list for deterministic output.
- `DataMaskingError` on strategy dispatch failure.
## Usage: Basic Example
```ts
import { maskObject } from '@suaveplan/data-masking/masker';
const result = maskObject(
{ user: { name: 'Alice', email: 'alice@example.com' } },
{ rules: [{ fields: ['user.email'], strategy: 'email' }] },
);
// result.masked.user.email → 'a***@e***.com'
// result.fieldsAffected → ['user.email']
```
## Usage: Advanced Example
```ts
import { maskObject, maskString, maskValue } from '@suaveplan/data-masking/masker';
// Full object masking with wildcards
const data = {
user: { name: 'John Doe', email: 'john@example.com', ssn: '123-45-6789' },
payment: { cardNumber: '4111-1111-1111-1111', cvv: '123' },
};
const result = maskObject(data, {
rules: [
{ fields: ['user.email'], strategy: 'email' },
{ fields: ['user.ssn', 'payment.cvv'], strategy: 'full' },
{ fields: ['payment.cardNumber'], strategy: 'credit-card' },
],
deep: true,
maskArrays: true,
});
// result.masked.user.email → 'j***@e***.com'
// result.masked.user.ssn → '***'
// result.masked.payment.cardNumber → '****-****-****-1111'
// result.masked.payment.cvv → '***'
// result.fieldsAffected.length → 4
// Free-form string masking
const log = 'User john@example.com called from +1-555-123-4567';
const masked = maskString(log, [
{ fields: ['*'], strategy: 'email' },
{ fields: ['*'], strategy: 'phone' },
]);
// masked → 'User j***@e***.com called from *-***-***-4567'
```
## API Reference
### `maskValue(value, strategy, rule)`
```ts
function maskValue(value: string, strategy: MaskStrategy, rule: MaskRule): string;
```
Dispatches `value` to the named strategy. Wraps any thrown error in a
`DataMaskingError` with code `DATA_MASKING.MASK_FAILED`.
### `maskObject<T>(obj, options)`
```ts
function maskObject<T extends Record<string, unknown>>(
obj: T,
options: MaskOptions,
): MaskResult<T>;
```
| Parameter | Type | Default | Description |
|------------------|---------------|---------|------------------------------------|
| `obj` | `T` | — | Source plain object. |
| `options.rules` | `MaskRule[]` | — | Ordered masking rules. |
| `options.deep` | `boolean` | `true` | Recurse into nested objects. |
| `options.maskArrays` | `boolean` | `true` | Mask array element values. |
**Returns** `MaskResult<T>` with `masked` clone and sorted `fieldsAffected`.
### `maskString(str, rules)`
```ts
function maskString(str: string, rules: MaskRule[]): string;
```
Scans `str` for embedded credit-card, email, and phone patterns and replaces
each detected match using the first rule with a matching strategy.
## Implementation Notes
- Object traversal uses plain `for...of` over `Object.keys` to avoid
prototype-chain pollution; `isPlainObject` guards against class instances,
`Date`, `RegExp`, etc., which are returned as-is.
- Array element paths use bracket notation `field[0]` to distinguish them
from nested object paths in the `fieldsAffected` list.
- The OTel `Meter` is obtained lazily on first call and cached module-wide to
avoid re-creating metric instruments on each invocation.
- `performance.now()` is used for duration measurement (available in Node.js
16+ and Bun natively; no polyfill required for the suaveplan target).
- `maskString` applies rules in category order (credit-card → email → phone)
to avoid double-masking where patterns overlap.

View File

@@ -0,0 +1,346 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
DATA_MASKING_CODES,
DataMaskingError,
} from "../../errors/data-masking-error.js";
import { _resetTokenCounter } from "../../strategies/tokenize.js";
import { maskObject, maskString, maskValue } from "./object-masker.js";
describe("maskValue", () => {
it("dispatches 'full' strategy", { timeout: 5000 }, () => {
const result = maskValue("secret", "full", {
fields: ["f"],
strategy: "full",
});
expect(result).toBe("***");
});
it("dispatches 'partial' strategy", { timeout: 5000 }, () => {
const result = maskValue("1234567890", "partial", {
fields: ["f"],
strategy: "partial",
keepLast: 4,
});
expect(result).toBe("******7890");
});
it("dispatches 'email' strategy", { timeout: 5000 }, () => {
const result = maskValue("john@example.com", "email", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("j***@e***.com");
});
it("dispatches 'phone' strategy", { timeout: 5000 }, () => {
const result = maskValue("+1-555-123-4567", "phone", {
fields: ["f"],
strategy: "phone",
});
expect(result).toBe("+*-***-***-4567");
});
it("dispatches 'credit-card' strategy", { timeout: 5000 }, () => {
const result = maskValue("4111-1111-1111-1111", "credit-card", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-****-****-1111");
});
it("dispatches 'hash' strategy", { timeout: 5000 }, () => {
const result = maskValue("hello", "hash", {
fields: ["f"],
strategy: "hash",
});
expect(result).toMatch(/^[0-9a-f]{8}$/);
});
it("dispatches 'preserve-format' strategy", { timeout: 5000 }, () => {
const result = maskValue("Hello123", "preserve-format", {
fields: ["f"],
strategy: "preserve-format",
});
expect(result).toBe("XXXXX000");
});
it("dispatches 'tokenize' strategy", { timeout: 5000 }, () => {
_resetTokenCounter();
const result = maskValue("secret", "tokenize", {
fields: ["f"],
strategy: "tokenize",
});
expect(result).toMatch(/^TOKEN_TOKENIZE_\d+$/);
});
});
describe("maskObject", () => {
const data = {
user: {
name: "John Doe",
email: "john@example.com",
ssn: "123-45-6789",
},
payment: {
cardNumber: "4111111111111111",
cvv: "123",
},
};
it("masks email with email strategy", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.email).toBe("j***@e***.com");
});
it("masks ssn and cvv with full strategy", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [{ fields: ["user.ssn", "payment.cvv"], strategy: "full" }],
});
expect(result.masked.user.ssn).toBe("***");
expect(result.masked.payment.cvv).toBe("***");
});
it("masks card number with credit-card strategy", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [
{ fields: ["payment.cardNumber"], strategy: "credit-card" },
],
});
expect(result.masked.payment.cardNumber).toBe("************1111");
});
it("reports all affected fields", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [
{ fields: ["user.email"], strategy: "email" },
{ fields: ["user.ssn", "payment.cvv"], strategy: "full" },
{ fields: ["payment.cardNumber"], strategy: "credit-card" },
],
});
expect(result.fieldsAffected).toHaveLength(4);
expect(result.fieldsAffected).toContain("user.email");
expect(result.fieldsAffected).toContain("user.ssn");
expect(result.fieldsAffected).toContain("payment.cvv");
expect(result.fieldsAffected).toContain("payment.cardNumber");
});
it("does not mutate the original object", { timeout: 5000 }, () => {
const original = {
user: { email: "john@example.com" },
};
maskObject(original, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(original.user.email).toBe("john@example.com");
});
it("does not mask unmatched fields", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.name).toBe("John Doe");
expect(result.masked.payment.cvv).toBe("123");
});
it("returns empty fieldsAffected when no rules match", {
timeout: 5000,
}, () => {
const result = maskObject(data, {
rules: [{ fields: ["nonexistent.field"], strategy: "full" }],
});
expect(result.fieldsAffected).toHaveLength(0);
});
it("skips nested objects when deep=false", { timeout: 5000 }, () => {
const nested = { user: { email: "john@example.com" } };
const result = maskObject(nested, {
rules: [{ fields: ["user.email"], strategy: "email" }],
deep: false,
});
// When deep=false, nested objects are not traversed, so email is not masked
expect(result.masked.user).toEqual({ email: "john@example.com" });
expect(result.fieldsAffected).toHaveLength(0);
});
it("masks array elements when maskArrays=true (default)", {
timeout: 5000,
}, () => {
const obj = { emails: ["alice@example.com", "bob@example.com"] };
const result = maskObject(obj, {
rules: [{ fields: ["emails[0]", "emails[1]"], strategy: "email" }],
});
expect(result.masked.emails[0]).toBe("a***@e***.com");
expect(result.masked.emails[1]).toBe("b***@e***.com");
});
it("passes through arrays unchanged when maskArrays=false", {
timeout: 5000,
}, () => {
const obj = { emails: ["alice@example.com"] };
const result = maskObject(obj, {
rules: [{ fields: ["emails[0]"], strategy: "email" }],
maskArrays: false,
});
expect(result.masked.emails[0]).toBe("alice@example.com");
});
it("passes null values through unchanged", { timeout: 5000 }, () => {
const obj = { user: { email: null as unknown as string } };
const result = maskObject(obj, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.email).toBeNull();
});
it("passes undefined values through unchanged", { timeout: 5000 }, () => {
const obj = { user: { email: undefined as unknown as string } };
const result = maskObject(obj, {
rules: [{ fields: ["user.email"], strategy: "email" }],
});
expect(result.masked.user.email).toBeUndefined();
});
it("returns sorted fieldsAffected list", { timeout: 5000 }, () => {
const result = maskObject(data, {
rules: [
{
fields: [
"user.ssn",
"payment.cvv",
"user.email",
"payment.cardNumber",
],
strategy: "full",
},
],
});
const sorted = [...result.fieldsAffected].sort();
expect(result.fieldsAffected).toEqual(sorted);
});
it("supports wildcard patterns in rules", { timeout: 5000 }, () => {
const obj = { user: { email: "john@example.com", phone: "555-1234" } };
const result = maskObject(obj, {
rules: [{ fields: ["user.*"], strategy: "full" }],
});
expect(result.masked.user.email).toBe("***");
expect(result.masked.user.phone).toBe("***");
});
});
describe("DataMaskingError", () => {
it("has correct name, code, and message", { timeout: 5000 }, () => {
const err = new DataMaskingError(
"test message",
DATA_MASKING_CODES.MASK_FAILED
);
expect(err.name).toBe("DataMaskingError");
expect(err.code).toBe("DATA_MASKING.MASK_FAILED");
expect(err.message).toBe("test message");
expect(err instanceof Error).toBe(true);
});
it("works with INVALID_RULE code", { timeout: 5000 }, () => {
const err = new DataMaskingError(
"invalid rule",
DATA_MASKING_CODES.INVALID_RULE
);
expect(err.code).toBe("DATA_MASKING.INVALID_RULE");
});
});
describe("maskObject edge cases", () => {
it("passes through number values unchanged", { timeout: 5000 }, () => {
const obj = { age: 42 as unknown as string };
const result = maskObject(obj, {
rules: [{ fields: ["age"], strategy: "full" }],
});
// numbers are not strings so they pass through even when matched
expect(result.masked.age).toBe(42);
});
it("passes through boolean values unchanged", { timeout: 5000 }, () => {
const obj = { active: true as unknown as string };
const result = maskObject(obj, {
rules: [{ fields: ["active"], strategy: "full" }],
});
expect(result.masked.active).toBe(true);
});
it("masks a nested object field that matches a rule by serialising it", {
timeout: 5000,
}, () => {
// When deep=true and a plain object field is matched, it gets JSON-serialised then masked
const obj = {
metadata: { secret: "value", count: 42 },
};
const result = maskObject(obj, {
rules: [{ fields: ["metadata"], strategy: "full" }],
});
expect(result.masked.metadata).toBe("***");
expect(result.fieldsAffected).toContain("metadata");
});
it("passes through Date values (non-plain-object) unchanged", {
timeout: 5000,
}, () => {
const d = new Date("2024-01-01");
const obj = { createdAt: d as unknown as string };
const result = maskObject(obj, {
rules: [{ fields: ["other"], strategy: "full" }],
});
expect(result.masked.createdAt).toBe(d);
});
});
describe("maskString", () => {
it("masks embedded email addresses", { timeout: 5000 }, () => {
const result = maskString("Contact john@example.com for help", [
{ fields: ["*"], strategy: "email" },
]);
expect(result).toContain("j***@e***.com");
expect(result).not.toContain("john@example.com");
});
it("masks embedded credit card numbers", { timeout: 5000 }, () => {
const result = maskString("Card: 4111-1111-1111-1111 was charged", [
{ fields: ["*"], strategy: "credit-card" },
]);
expect(result).toContain("****-****-****-1111");
expect(result).not.toContain("4111-1111-1111-1111");
});
it("masks embedded phone numbers", { timeout: 5000 }, () => {
const result = maskString("Call +1-555-123-4567 now", [
{ fields: ["*"], strategy: "phone" },
]);
expect(result).toContain("4567");
expect(result).not.toContain("555-123-4567");
});
it("returns original string when no rules match patterns", {
timeout: 5000,
}, () => {
const original = "No PII here just text";
const result = maskString(original, [
{ fields: ["*"], strategy: "full" },
]);
expect(result).toBe(original);
});
it("handles multiple PII types in one string", { timeout: 5000 }, () => {
const log = "User john@example.com called 5551234567";
const result = maskString(log, [
{ fields: ["*"], strategy: "email" },
{ fields: ["*"], strategy: "phone" },
]);
expect(result).toContain("j***@e***.com");
expect(result).toContain("4567");
});
beforeEach(() => {
_resetTokenCounter();
});
});

View File

@@ -0,0 +1,298 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
} from "@opentelemetry/api";
import { maskCreditCard } from "../../strategies/credit-card.js";
import { maskEmail } from "../../strategies/email.js";
import { maskFull } from "../../strategies/full.js";
import { maskHash } from "../../strategies/hash.js";
import { maskPartial } from "../../strategies/partial.js";
import { maskPhone } from "../../strategies/phone.js";
import { maskPreserveFormat } from "../../strategies/preserve-format.js";
import { maskTokenize } from "../../strategies/tokenize.js";
import type {
MaskOptions,
MaskResult,
MaskRule,
MaskStrategy,
} from "../../types/mask-types.js";
import { matchPath } from "../../utils/path-matcher.js";
// ---------------------------------------------------------------------------
// OTel instrumentation
// ---------------------------------------------------------------------------
interface MaskMetrics {
maskTotal: Counter;
maskDuration: Histogram;
}
let _metrics: MaskMetrics | undefined;
function getMetrics(provider?: MeterProvider): MaskMetrics {
if (_metrics) return _metrics;
const meter: Meter = (provider ?? metrics.getMeterProvider()).getMeter(
"@suaveplan/data-masking",
"0.1.0"
);
_metrics = {
maskTotal: meter.createCounter("data_masking.mask.total", {
description: "Number of mask operations performed",
}),
maskDuration: meter.createHistogram("data_masking.mask.duration_ms", {
description: "Duration of maskObject calls in milliseconds",
unit: "ms",
}),
};
return _metrics;
}
// ---------------------------------------------------------------------------
// Strategy dispatch
// ---------------------------------------------------------------------------
/**
* Dispatch a masking strategy for a concrete string value.
*
* @param value - The value to mask.
* @param strategy - Which masking strategy to apply.
* @param rule - The full `MaskRule` (passed to strategies for options).
* @returns The masked string.
* @throws `DataMaskingError` with `MASK_FAILED` if strategy execution throws.
*/
export function maskValue(
value: string,
strategy: MaskStrategy,
rule: MaskRule
): string {
switch (strategy) {
case "full":
return maskFull(value, rule);
case "partial":
return maskPartial(value, rule);
case "email":
return maskEmail(value, rule);
case "phone":
return maskPhone(value, rule);
case "credit-card":
return maskCreditCard(value, rule);
case "hash":
return maskHash(value, rule);
case "tokenize":
return maskTokenize(value, rule);
case "preserve-format":
return maskPreserveFormat(value, rule);
}
}
// ---------------------------------------------------------------------------
// Object traversal
// ---------------------------------------------------------------------------
/**
* Apply masking rules to a plain-object graph and return a deep clone with
* sensitive fields masked.
*
* The input object is never mutated. Array elements are masked in-place on
* the cloned copy when `options.maskArrays` is `true` (default). Nested
* objects are traversed when `options.deep` is `true` (default).
*
* OTel counters and histograms are recorded on every call.
*
* @param obj - The source object to mask.
* @param options - Rules and traversal flags.
* @returns A `MaskResult` containing the masked clone and the list of affected
* field paths.
*/
export function maskObject<T extends Record<string, unknown>>(
obj: T,
options: MaskOptions
): MaskResult<T> {
const start = performance.now();
const fieldsAffected: string[] = [];
const cloned = traverseObject(
obj,
options,
"",
fieldsAffected,
/* depth */ 0
) as T;
const durationMs = performance.now() - start;
const m = getMetrics();
m.maskDuration.record(durationMs);
return { masked: cloned, fieldsAffected: fieldsAffected.sort() };
}
// ---------------------------------------------------------------------------
// Recursive traversal helpers
// ---------------------------------------------------------------------------
function traverseObject(
obj: Record<string, unknown>,
options: MaskOptions,
prefix: string,
affected: string[],
depth: number
): Record<string, unknown> {
const deep = options.deep !== false;
const clone: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
const fieldPath = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
clone[key] = traverseValue(
value,
fieldPath,
options,
affected,
depth,
deep
);
}
return clone;
}
function traverseValue(
value: unknown,
fieldPath: string,
options: MaskOptions,
affected: string[],
depth: number,
deep: boolean
): unknown {
if (value === null || value === undefined) return value;
// Check if any rule matches this field path.
const matchedRule = findRule(fieldPath, options.rules);
if (typeof value === "string") {
if (matchedRule) {
const masked = maskValue(value, matchedRule.strategy, matchedRule);
affected.push(fieldPath);
getMetrics().maskTotal.add(1, { strategy: matchedRule.strategy });
return masked;
}
return value;
}
if (Array.isArray(value)) {
if (options.maskArrays === false) return value;
return value.map((item, idx) => {
const itemPath = `${fieldPath}[${idx}]`;
return traverseValue(
item,
itemPath,
options,
affected,
depth + 1,
deep
);
});
}
if (deep && depth >= 0 && isPlainObject(value)) {
// If the object field itself is matched, mask the whole value as full.
if (matchedRule) {
const serialized = JSON.stringify(value);
const masked = maskValue(
serialized,
matchedRule.strategy,
matchedRule
);
affected.push(fieldPath);
getMetrics().maskTotal.add(1, { strategy: matchedRule.strategy });
return masked;
}
return traverseObject(
value as Record<string, unknown>,
options,
fieldPath,
affected,
depth + 1
);
}
// For non-deep traversal, only match string values at the top level.
if (!deep && isPlainObject(value)) {
return value;
}
return value;
}
function findRule(fieldPath: string, rules: MaskRule[]): MaskRule | undefined {
for (const rule of rules) {
for (const pattern of rule.fields) {
if (matchPath(fieldPath, pattern)) {
return rule;
}
}
}
return undefined;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
// ---------------------------------------------------------------------------
// String masking
// ---------------------------------------------------------------------------
/**
* Detect embedded PII patterns in a free-form string and apply matching rules.
*
* Scans the string for substrings that look like email addresses, phone numbers,
* or credit-card numbers and replaces them according to the supplied rules.
* Rules are applied in declaration order; the first matching rule wins for each
* detected token.
*
* @param str - The free-form string to inspect.
* @param rules - The ordered set of masking rules.
* @returns A new string with detected PII replaced.
*/
export function maskString(str: string, rules: MaskRule[]): string {
let result = str;
// Credit card pattern: groups of 4 digits separated by spaces or hyphens,
// or 13-19 consecutive digits.
const ccRule = rules.find((r) => r.strategy === "credit-card");
if (ccRule) {
result = result.replace(
/\b(?:\d{4}[-\s]){3}\d{4}\b|\b\d{13,19}\b/g,
(match) => maskValue(match, "credit-card", ccRule)
);
}
// Email pattern.
const emailRule = rules.find((r) => r.strategy === "email");
if (emailRule) {
result = result.replace(
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
(match) => maskValue(match, "email", emailRule)
);
}
// Phone pattern: E.164, US, international with separators.
const phoneRule = rules.find((r) => r.strategy === "phone");
if (phoneRule) {
result = result.replace(
/(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
(match) => maskValue(match, "phone", phoneRule)
);
}
return result;
}

View File

@@ -0,0 +1,120 @@
# data-masking-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a stable error-code registry and a typed `DataMaskingError` class for
all failures that originate inside `@suaveplan/data-masking`. Keeping errors
isolated to a single file makes it easy for callers to perform narrow
`instanceof` checks and to discriminate specific failure modes via the `code`
property without resorting to fragile message-string matching.
The package is intentionally a leaf node — it carries zero `@suaveplan/*`
runtime dependencies — so `DataMaskingError` extends the native `Error` class
directly rather than `SuaveplanError`.
## Features
- `DATA_MASKING_CODES` constant object: two stable string codes exported as
`as const` for full TypeScript narrowing.
- `DataMaskingCode` union type derived from `DATA_MASKING_CODES`.
- `DataMaskingError` class: extends `Error`, sets `name = "DataMaskingError"`,
exposes a readonly `code: DataMaskingCode` property.
- Tree-shakeable — no side-effects, compatible with `"sideEffects": false`.
## Usage: Basic Example
```ts
import {
DataMaskingError,
DATA_MASKING_CODES,
} from '@suaveplan/data-masking/errors';
try {
throw new DataMaskingError(
'Strategy "unknown" is not recognised',
DATA_MASKING_CODES.INVALID_RULE,
);
} catch (err) {
if (err instanceof DataMaskingError) {
console.error(err.code); // 'DATA_MASKING.INVALID_RULE'
console.error(err.name); // 'DataMaskingError'
console.error(err.message);
}
}
```
## Usage: Advanced Example
```ts
import {
DataMaskingError,
DATA_MASKING_CODES,
type DataMaskingCode,
} from '@suaveplan/data-masking/errors';
function handleMaskingError(err: unknown): void {
if (!(err instanceof DataMaskingError)) throw err;
const actions: Record<DataMaskingCode, () => void> = {
[DATA_MASKING_CODES.INVALID_RULE]: () =>
console.warn('Rule validation failed — check your MaskRule definitions'),
[DATA_MASKING_CODES.MASK_FAILED]: () =>
console.error('Masking strategy threw during execution'),
};
actions[err.code]?.();
}
```
## API Reference
### `DATA_MASKING_CODES`
```ts
const DATA_MASKING_CODES: {
readonly INVALID_RULE: 'DATA_MASKING.INVALID_RULE';
readonly MASK_FAILED: 'DATA_MASKING.MASK_FAILED';
};
```
Stable string constants that identify each error category. Values are
intentionally namespaced with `DATA_MASKING.` to avoid collisions in shared
error-monitoring pipelines.
### `DataMaskingCode`
```ts
type DataMaskingCode = 'DATA_MASKING.INVALID_RULE' | 'DATA_MASKING.MASK_FAILED';
```
Union type derived from `DATA_MASKING_CODES`. Use this for exhaustive `switch`
statements or discriminated-union narrowing.
### `DataMaskingError`
```ts
class DataMaskingError extends Error {
readonly code: DataMaskingCode;
constructor(message: string, code: DataMaskingCode): DataMaskingError;
}
```
| Property | Type | Description |
|----------|-------------------|----------------------------------------|
| `name` | `string` | Always `"DataMaskingError"`. |
| `code` | `DataMaskingCode` | The stable machine-readable error code.|
| `message`| `string` | Human-readable description of failure. |
## Implementation Notes
- The `name` property is set in the constructor body (not via a static field)
to ensure correct behaviour after `Object.setPrototypeOf` in transpiled ES5
environments, though suaveplan targets ES2022+ natively.
- No stack-trace manipulation is performed — the default `Error` constructor
captures a stack trace automatically in V8 and Bun runtimes.
- `DATA_MASKING_CODES` is declared `as const` so TypeScript infers the narrow
string-literal type for each member; this enables exhaustive checks on
`DataMaskingCode` without a separate enum.

View File

@@ -0,0 +1,50 @@
/**
* Error types for `@suaveplan/data-masking`.
*
* Provides a stable code registry and a typed error class for all masking
* failures. Codes follow the `DATA_MASKING.<CATEGORY>` namespace convention
* so callers can discriminate errors without matching on message strings.
*/
/**
* Stable error code registry for the data-masking package.
*
* - `DATA_MASKING.INVALID_RULE` — a supplied `MaskRule` is structurally
* invalid (e.g. unknown strategy, empty field list).
* - `DATA_MASKING.MASK_FAILED` — an error occurred while applying a masking
* strategy to a specific value (e.g. regex failure, unexpected value shape).
*/
export const DATA_MASKING_CODES = {
INVALID_RULE: "DATA_MASKING.INVALID_RULE",
MASK_FAILED: "DATA_MASKING.MASK_FAILED",
} as const;
export type DataMaskingCode =
(typeof DATA_MASKING_CODES)[keyof typeof DATA_MASKING_CODES];
/**
* Typed error for all `@suaveplan/data-masking` failures.
*
* Extends the built-in `Error` with a strongly-typed `code` property drawn
* from `DATA_MASKING_CODES`, giving callers narrow discrimination without
* introducing a dependency on `@suaveplan/error` (this is a leaf package).
*
* @example
* ```ts
* import { DataMaskingError, DATA_MASKING_CODES } from '@suaveplan/data-masking/errors';
*
* throw new DataMaskingError(
* 'Strategy "unknown" is not supported',
* DATA_MASKING_CODES.INVALID_RULE,
* );
* ```
*/
export class DataMaskingError extends Error {
readonly code: DataMaskingCode;
public constructor(message: string, code: DataMaskingCode) {
super(message);
this.name = "DataMaskingError";
this.code = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
DATA_MASKING_CODES,
type DataMaskingCode,
DataMaskingError,
} from "./data-masking-error";

View File

@@ -0,0 +1,34 @@
// Core masking functions
export {
maskObject,
maskString,
maskValue,
} from "./core/masker/object-masker.js";
export type { DataMaskingCode } from "./errors/data-masking-error.js";
// Error types
export {
DATA_MASKING_CODES,
DataMaskingError,
} from "./errors/data-masking-error.js";
export type {
MaskOptionsInput,
MaskRuleInput,
MaskStrategyInput,
} from "./schemas/mask-rule.schema.js";
// Schemas
export {
MaskOptionsSchema,
MaskRuleSchema,
MaskStrategySchema,
} from "./schemas/mask-rule.schema.js";
// Public types
export type {
MaskOptions,
MaskResult,
MaskRule,
MaskStrategy,
} from "./types/mask-types.js";
// Path matcher utility
export { matchPath } from "./utils/path-matcher.js";

View File

@@ -0,0 +1,8 @@
export {
type MaskOptionsInput,
MaskOptionsSchema,
type MaskRuleInput,
MaskRuleSchema,
type MaskStrategyInput,
MaskStrategySchema,
} from "./mask-rule.schema";

View File

@@ -0,0 +1,122 @@
# mask-rule.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod schemas for all public configuration types in
`@suaveplan/data-masking`. Runtime validation of `MaskRule` and `MaskOptions`
objects is important when rules originate from external sources such as API
request bodies, configuration files, or database records. Using these schemas
before calling `maskObject` prevents silent mis-configurations — for example, a
rule with an empty `fields` array or an unrecognised `strategy` string — from
causing confusing runtime behaviour.
Exporting the schemas as a dedicated subpath (`./schemas`) allows tree-shaking
in production bundles that never parse external rule definitions.
## Features
- `MaskStrategySchema` — exhaustive `z.enum` of all eight strategy strings.
- `MaskRuleSchema` — validates `fields` (non-empty array of non-empty strings),
`strategy`, and optional `keepLast`, `keepFirst`, `replacement` fields.
- `MaskOptionsSchema` — validates `rules` (at least one required), `deep`,
`maskArrays`.
- Input types (`MaskStrategyInput`, `MaskRuleInput`, `MaskOptionsInput`)
exported for use in form and API validation pipelines.
## Usage: Basic Example
```ts
import { MaskRuleSchema } from '@suaveplan/data-masking/schemas';
const parsed = MaskRuleSchema.safeParse({
fields: ['user.email'],
strategy: 'email',
});
if (!parsed.success) {
console.error(parsed.error.issues);
} else {
console.log(parsed.data.strategy); // 'email'
}
```
## Usage: Advanced Example
```ts
import { MaskOptionsSchema } from '@suaveplan/data-masking/schemas';
import { maskObject } from '@suaveplan/data-masking';
function safeMaskObject(
data: Record<string, unknown>,
rawOptions: unknown,
) {
const options = MaskOptionsSchema.parse(rawOptions); // throws ZodError on invalid input
return maskObject(data, options);
}
// Use from an API request body
const result = safeMaskObject(userData, req.body.maskOptions);
```
## API Reference
### `MaskStrategySchema`
```ts
const MaskStrategySchema: z.ZodEnum<[
'full', 'partial', 'email', 'phone',
'credit-card', 'hash', 'tokenize', 'preserve-format'
]>;
```
Validates that a string is one of the eight supported masking strategy
identifiers.
### `MaskRuleSchema`
```ts
const MaskRuleSchema: z.ZodObject<{
fields: z.ZodArray<z.ZodString>; // min 1 item, each min 1 char
strategy: typeof MaskStrategySchema;
keepLast?: z.ZodNumber; // int, non-negative
keepFirst?: z.ZodNumber; // int, non-negative
replacement?: z.ZodString;
}>;
```
### `MaskOptionsSchema`
```ts
const MaskOptionsSchema: z.ZodObject<{
rules: z.ZodArray<typeof MaskRuleSchema>; // min 1 item
deep?: z.ZodBoolean;
maskArrays?: z.ZodBoolean;
}>;
```
### Input types
```ts
type MaskStrategyInput = z.input<typeof MaskStrategySchema>;
type MaskRuleInput = z.input<typeof MaskRuleSchema>;
type MaskOptionsInput = z.input<typeof MaskOptionsSchema>;
```
These are the raw (un-coerced) input types, useful for TypeScript annotations
in form handlers and API route definitions before calling `.parse()`.
## Implementation Notes
- `z.number().int().nonnegative()` is used for `keepLast` and `keepFirst` to
reject fractional or negative values that would produce nonsensical results
in the `partial` strategy.
- `z.array(...).min(1)` on both `fields` and `rules` prevents silent no-op
configurations — a rule with no fields or an options object with no rules
would be a programming error.
- The schema file is excluded from coverage measurement (via `*.schema.ts`
exclusion in `vitest.config.ts`) because it is declarative Zod schema
definition code; meaningful coverage comes from integration tests of the
schemas via `safeParse`/`parse`, not line-by-line execution tracing.

View File

@@ -0,0 +1,45 @@
import z from "zod";
/**
* Zod schema for the `MaskStrategy` union type.
*/
export const MaskStrategySchema = z.enum([
"full",
"partial",
"email",
"phone",
"credit-card",
"hash",
"tokenize",
"preserve-format",
]);
/**
* Zod schema for `MaskRule`.
*
* Validates that `fields` is a non-empty array of non-empty strings and that
* optional numeric options (`keepLast`, `keepFirst`) are non-negative integers.
*/
export const MaskRuleSchema = z.object({
fields: z.array(z.string().min(1)).min(1),
strategy: MaskStrategySchema,
keepLast: z.number().int().nonnegative().optional(),
keepFirst: z.number().int().nonnegative().optional(),
replacement: z.string().optional(),
});
/**
* Zod schema for `MaskOptions`.
*
* At least one rule must be supplied; `deep` and `maskArrays` default to
* `true` when omitted.
*/
export const MaskOptionsSchema = z.object({
rules: z.array(MaskRuleSchema).min(1),
deep: z.boolean().optional(),
maskArrays: z.boolean().optional(),
});
export type MaskStrategyInput = z.input<typeof MaskStrategySchema>;
export type MaskRuleInput = z.input<typeof MaskRuleSchema>;
export type MaskOptionsInput = z.input<typeof MaskOptionsSchema>;

View File

@@ -0,0 +1,82 @@
# credit-card strategy
## Purpose
The `credit-card` masking strategy produces the PCI-DSS-compatible truncation
format for payment card numbers: last 4 digits visible, all preceding digit
positions replaced with `*`, formatting separators preserved. This aligns
with the PCI DSS 4.0 requirement that primary account numbers (PANs) must be
rendered unreadable in logs and displays, while retaining just enough
information for card identification and customer service operations.
## Features
- Last-4-digit preservation regardless of separator style.
- Hyphens, spaces, and other non-digit characters pass through unchanged.
- Handles formatted (`4111-1111-1111-1111`) and raw (`4111111111111111`) inputs.
- No regex capture groups — single linear pass, O(n) complexity.
## Usage: Basic Example
```ts
import { maskCreditCard } from '@suaveplan/data-masking/strategies/credit-card';
const masked = maskCreditCard('4111-1111-1111-1111', {
fields: ['cardNumber'],
strategy: 'credit-card',
});
// masked → '****-****-****-1111'
```
## Usage: Advanced Example
```ts
import { maskCreditCard } from '@suaveplan/data-masking/strategies/credit-card';
// No separators
maskCreditCard('5500005555555559', { fields: ['pan'], strategy: 'credit-card' });
// → '************5559'
// Space-separated format
maskCreditCard('4111 1111 1111 1111', { fields: ['pan'], strategy: 'credit-card' });
// → '**** **** **** 1111'
// Amex 15-digit
maskCreditCard('3714 496353 98431', { fields: ['pan'], strategy: 'credit-card' });
// → '**** ****** *8431'
// No digits at all → passthrough
maskCreditCard('CARD-ON-FILE', { fields: ['pan'], strategy: 'credit-card' });
// → 'CARD-ON-FILE'
```
## API Reference
### `maskCreditCard(value, rule)`
```ts
function maskCreditCard(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|-----------------------------------------------------------|
| `value` | `string` | The original card number in any format. |
| `rule` | `MaskRule` | Driving rule (no fields consumed by credit-card strategy).|
**Returns** the masked card number with last 4 digits preserved and all other
digit positions replaced by `*`.
**Does not throw.** Returns the original value unchanged when no digit
characters are found.
## Implementation Notes
- The algorithm is identical in structure to the `phone` strategy: extract all
digits to find the `keepFrom` threshold, then rebuild the string in a single
pass. The two strategies are kept as separate files to allow divergent
evolution (e.g. future BIN-preservation or Luhn-check validation in the
credit-card variant).
- `Math.max(0, digits.length - 4)` ensures cards with 4 or fewer digits are
returned unchanged, consistent with the principle of least surprise.
- The `rule` parameter is silently voided to satisfy the uniform strategy
interface; no `MaskRule` fields are read.

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskCreditCard } from "./credit-card.js";
describe("maskCreditCard", () => {
it("masks 4111-1111-1111-1111 keeping last 4 digits", {
timeout: 5000,
}, () => {
const result = maskCreditCard("4111-1111-1111-1111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-****-****-1111");
});
it("handles unformatted 16-digit card number", { timeout: 5000 }, () => {
const result = maskCreditCard("4111111111111111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("************1111");
});
it("handles space-separated card number", { timeout: 5000 }, () => {
const result = maskCreditCard("4111 1111 1111 1111", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("**** **** **** 1111");
});
it("handles 15-digit Amex format", { timeout: 5000 }, () => {
// 371449635398431 — 15 digits, keepFrom = 11
const result = maskCreditCard("3714-496353-98431", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-******-*8431");
});
it("returns value unchanged when no digits present", {
timeout: 5000,
}, () => {
const result = maskCreditCard("CARD-ON-FILE", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("CARD-ON-FILE");
});
it("preserves all digits for 4-digit input", { timeout: 5000 }, () => {
const result = maskCreditCard("1234", {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("1234");
});
it("preserves separators in output", { timeout: 5000 }, () => {
const input = "5500-0055-5555-5559";
const result = maskCreditCard(input, {
fields: ["f"],
strategy: "credit-card",
});
expect(result).toBe("****-****-****-5559");
});
});

View File

@@ -0,0 +1,39 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Credit-card masking strategy.
*
* Keeps the last 4 digits of a credit-card number and replaces all preceding
* digit positions with `*`, while preserving formatting separators (hyphens,
* spaces). Produces the industry-standard masked form, e.g.:
*
* - `4111-1111-1111-1111` → `****-****-****-1111`
* - `4111111111111111` → `************1111`
*
* @param value - The original card number, optionally formatted with
* separators.
* @param rule - The `MaskRule` (not consumed by this strategy).
* @returns The masked card number.
*/
export function maskCreditCard(value: string, rule: MaskRule): string {
void rule;
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return value;
const keepFrom = Math.max(0, digits.length - 4);
let digitsSeen = 0;
let result = "";
for (const ch of value) {
if (/\d/.test(ch)) {
if (digitsSeen < keepFrom) {
result += "*";
} else {
result += ch;
}
digitsSeen++;
} else {
result += ch;
}
}
return result;
}

View File

@@ -0,0 +1,75 @@
# email strategy
## Purpose
The `email` masking strategy anonymises email addresses while keeping the
output immediately recognisable as an email address. Retaining the first
character of the local part and of the domain name — plus the full TLD — gives
operations and support teams just enough context to identify a domain category
(e.g. `.com`, `.io`, `.co.uk`) and the rough shape of an address without
exposing personally identifiable information. This is the standard form used
in GDPR-compliant audit logs and support ticketing systems.
## Features
- Preserves first character of the local-part (`u***@...`).
- Preserves first character of the domain name (`...@d***...`).
- Preserves the TLD verbatim (`.com`, `.co.uk`, etc.).
- Gracefully handles malformed inputs (no `@` sign) by returning `"***"`.
- No external dependencies — pure string slicing.
## Usage: Basic Example
```ts
import { maskEmail } from '@suaveplan/data-masking/strategies/email';
const masked = maskEmail('john.doe@example.com', { fields: ['email'], strategy: 'email' });
// masked → 'j***@e***.com'
```
## Usage: Advanced Example
```ts
import { maskEmail } from '@suaveplan/data-masking/strategies/email';
// Long TLD preserved
maskEmail('alice@subdomain.company.co.uk', { fields: ['email'], strategy: 'email' });
// → 'a***@s***.co.uk'
// No @ in input → fallback
maskEmail('not-an-email', { fields: ['email'], strategy: 'email' });
// → '***'
// Single-char local part
maskEmail('x@y.io', { fields: ['email'], strategy: 'email' });
// → 'x***@y***.io'
```
## API Reference
### `maskEmail(value, rule)`
```ts
function maskEmail(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|---------------------------------------------------------|
| `value` | `string` | The original email address. |
| `rule` | `MaskRule` | Driving rule (no fields are read from it for email). |
**Returns** a string in the form `u***@d***.tld`, or `"***"` when `value`
contains no `@` character.
**Does not throw.**
## Implementation Notes
- The TLD is identified as everything from the **last** `.` in the domain
portion, which handles multi-part TLDs like `.co.uk` correctly.
- If the domain portion contains no `.` (uncommon in practice) the TLD is
treated as empty and the output becomes `d***`.
- The `rule` parameter is accepted to satisfy the uniform strategy interface
but none of its fields are consumed by the email strategy.
- Masking is applied to the parsed components, not via regex substitution,
which avoids regex-denial-of-service risks on adversarially crafted inputs.

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskEmail } from "./email.js";
describe("maskEmail", () => {
it("masks john@example.com correctly", { timeout: 5000 }, () => {
expect(
maskEmail("john@example.com", { fields: ["f"], strategy: "email" })
).toBe("j***@e***.com");
});
it("preserves TLD", { timeout: 5000 }, () => {
const result = maskEmail("alice@acme.org", {
fields: ["f"],
strategy: "email",
});
expect(result).toMatch(/\.org$/);
expect(result).toBe("a***@a***.org");
});
it("handles multi-part TLD (.co.uk)", { timeout: 5000 }, () => {
// lastIndexOf splits at the final dot: domainName="example.co", tld=".uk"
// First char of domainName "example.co" is "e"
const result = maskEmail("bob@example.co.uk", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("b***@e***.uk");
});
it("returns '***' for value without @ sign", { timeout: 5000 }, () => {
expect(
maskEmail("not-an-email", { fields: ["f"], strategy: "email" })
).toBe("***");
});
it("handles single-char local part", { timeout: 5000 }, () => {
expect(maskEmail("x@y.io", { fields: ["f"], strategy: "email" })).toBe(
"x***@y***.io"
);
});
it("masks subdomain correctly", { timeout: 5000 }, () => {
const result = maskEmail("user@mail.company.com", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("u***@m***.com");
});
it("handles uppercase local part", { timeout: 5000 }, () => {
const result = maskEmail("John@Example.com", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("J***@E***.com");
});
it("handles empty local part (starts with @)", { timeout: 5000 }, () => {
// local.length === 0 → localFirst = ""
const result = maskEmail("@example.com", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("***@e***.com");
});
it("handles domain with no dot (no TLD)", { timeout: 5000 }, () => {
// dotIdx = -1, domainName = full domain, tld = ""
const result = maskEmail("user@localhost", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("u***@l***");
});
it("handles empty domain (email ends with @)", { timeout: 5000 }, () => {
// domain = "", dotIdx = -1, domainName = "", domainFirst = ""
const result = maskEmail("user@", {
fields: ["f"],
strategy: "email",
});
expect(result).toBe("u***@***");
});
});

View File

@@ -0,0 +1,36 @@
import type { MaskRule } from "../types/mask-types.js";
/**
* Email-address masking strategy.
*
* Produces a format-recognisable but anonymised representation:
* `u***@d***.tld` where `u` is the first character of the local part and `d`
* is the first character of the domain name. The TLD (everything after the
* final `.` in the domain) is preserved verbatim so the email is still
* recognisable as a specific domain type (`.com`, `.org`, `.io`, etc.).
*
* @param value - The original email address string.
* @param rule - The `MaskRule` driving this masking operation (unused fields
* are silently ignored).
* @returns The masked email string, or `"***"` if the input is not a
* recognisable email address.
*/
export function maskEmail(value: string, rule: MaskRule): string {
void rule;
const atIdx = value.indexOf("@");
if (atIdx < 0) return "***";
const local = value.slice(0, atIdx);
const domain = value.slice(atIdx + 1);
const localFirst = local.length > 0 ? local[0] : "";
// Use the last dot in the domain to split domain name from TLD.
// This consistently picks the final label as the TLD, which works well
// for common cases like .com, .org, .io, and for "mail.company.com" → ".com".
const dotIdx = domain.lastIndexOf(".");
const domainName = dotIdx > 0 ? domain.slice(0, dotIdx) : domain;
const tld = dotIdx > 0 ? domain.slice(dotIdx) : "";
const domainFirst = domainName.length > 0 ? domainName[0] : "";
return `${localFirst}***@${domainFirst}***${tld}`;
}

View File

@@ -0,0 +1,67 @@
# full strategy
## Purpose
The `full` masking strategy completely replaces an input string with a
configurable replacement sentinel. It is the most aggressive masking mode
available in `@suaveplan/data-masking`: no characters from the original value
are preserved, so the output reveals nothing about length, structure, or
content. This strategy is appropriate for highly sensitive fields such as
passwords, SSNs, or security codes where even partial exposure is unacceptable.
## Features
- Zero information leakage — original value is discarded entirely.
- Customisable replacement via `MaskRule.replacement`; defaults to `"***"`.
- Constant-time execution (no iteration over the input string).
## Usage: Basic Example
```ts
import { maskFull } from '@suaveplan/data-masking/strategies/full';
const masked = maskFull('super-secret-password', { fields: ['password'], strategy: 'full' });
// masked → '***'
```
## Usage: Advanced Example
```ts
import { maskFull } from '@suaveplan/data-masking/strategies/full';
// Custom replacement sentinel
const masked = maskFull('123-45-6789', {
fields: ['ssn'],
strategy: 'full',
replacement: '[REDACTED]',
});
// masked → '[REDACTED]'
```
## API Reference
### `maskFull(value, rule)`
```ts
function maskFull(value: string, rule: MaskRule): string;
```
| Parameter | Type | Description |
|-----------|------------|---------------------------------------------------------|
| `value` | `string` | The original value to mask (immediately discarded). |
| `rule` | `MaskRule` | Driving rule; `rule.replacement` used when provided. |
**Returns** `rule.replacement` if set, otherwise `"***"`.
**Does not throw.**
## Implementation Notes
- The `value` parameter is accepted but immediately voided to satisfy the
uniform `mask(value, rule): string` strategy interface.
- `rule.replacement` may be any string, including the empty string `""`.
Callers that want a custom sentinel (e.g. `"[REDACTED]"`, `"N/A"`) set this
field on their `MaskRule`.
- Unlike `partial`, `email`, or `preserve-format` strategies, the `full`
strategy cannot be reversed or detected — it is appropriate for
security-critical fields that must not leak data to downstream systems.

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { maskFull } from "./full.js";
describe("maskFull", () => {
it("returns default replacement '***' when no replacement specified", {
timeout: 5000,
}, () => {
expect(maskFull("secret", { fields: ["f"], strategy: "full" })).toBe(
"***"
);
});
it("uses custom replacement when provided", { timeout: 5000 }, () => {
expect(
maskFull("secret", {
fields: ["f"],
strategy: "full",
replacement: "[REDACTED]",
})
).toBe("[REDACTED]");
});
it("uses empty string as replacement when explicitly set", {
timeout: 5000,
}, () => {
expect(
maskFull("secret", {
fields: ["f"],
strategy: "full",
replacement: "",
})
).toBe("");
});
it("discards the original value entirely", { timeout: 5000 }, () => {
const result = maskFull("sensitive-data-123", {
fields: ["f"],
strategy: "full",
});
expect(result).not.toContain("sensitive");
expect(result).toBe("***");
});
it("handles empty input string", { timeout: 5000 }, () => {
expect(maskFull("", { fields: ["f"], strategy: "full" })).toBe("***");
});
it("handles very long input string", { timeout: 5000 }, () => {
expect(
maskFull("a".repeat(1000), { fields: ["f"], strategy: "full" })
).toBe("***");
});
});

Some files were not shown because too many files have changed in this diff Show More