Initial Commit
This commit is contained in:
29
packages/foundation/data/README.md
Normal file
29
packages/foundation/data/README.md
Normal 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 -->
|
||||
1
packages/foundation/data/_intro.md
Normal file
1
packages/foundation/data/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Data manipulation, masking, deduplication, diffing, and byte utilities.
|
||||
95
packages/foundation/data/bytes/README.md
Normal file
95
packages/foundation/data/bytes/README.md
Normal 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 |
|
||||
|
||||
94
packages/foundation/data/bytes/package.json
Normal file
94
packages/foundation/data/bytes/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
103
packages/foundation/data/bytes/src/encoding/encoding.md
Normal file
103
packages/foundation/data/bytes/src/encoding/encoding.md
Normal 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).
|
||||
@@ -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",
|
||||
});
|
||||
110
packages/foundation/data/bytes/src/encoding/encoding.test.ts
Normal file
110
packages/foundation/data/bytes/src/encoding/encoding.test.ts
Normal 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
162
packages/foundation/data/bytes/src/encoding/encoding.ts
Normal file
162
packages/foundation/data/bytes/src/encoding/encoding.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
9
packages/foundation/data/bytes/src/encoding/index.ts
Normal file
9
packages/foundation/data/bytes/src/encoding/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
bytesToHex,
|
||||
bytesToUtf8,
|
||||
hexToBytes,
|
||||
utf8ToBytes,
|
||||
} from "./encoding.js";
|
||||
export { Base64StringSchema, HexStringSchema } from "./encoding.schemas.js";
|
||||
89
packages/foundation/data/bytes/src/errors/bytes-error.md
Normal file
89
packages/foundation/data/bytes/src/errors/bytes-error.md
Normal 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.
|
||||
32
packages/foundation/data/bytes/src/errors/bytes-error.ts
Normal file
32
packages/foundation/data/bytes/src/errors/bytes-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
packages/foundation/data/bytes/src/errors/index.ts
Normal file
1
packages/foundation/data/bytes/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BYTES_CODES, type BytesCode, BytesError } from "./bytes-error.js";
|
||||
39
packages/foundation/data/bytes/src/index.ts
Normal file
39
packages/foundation/data/bytes/src/index.ts
Normal 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";
|
||||
5
packages/foundation/data/bytes/src/types/index.ts
Normal file
5
packages/foundation/data/bytes/src/types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
BytesInput,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
} from "./types.js";
|
||||
78
packages/foundation/data/bytes/src/types/types.md
Normal file
78
packages/foundation/data/bytes/src/types/types.md
Normal 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.
|
||||
27
packages/foundation/data/bytes/src/types/types.ts
Normal file
27
packages/foundation/data/bytes/src/types/types.ts
Normal 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;
|
||||
15
packages/foundation/data/bytes/src/uint8/index.ts
Normal file
15
packages/foundation/data/bytes/src/uint8/index.ts
Normal 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";
|
||||
113
packages/foundation/data/bytes/src/uint8/uint8.md
Normal file
113
packages/foundation/data/bytes/src/uint8/uint8.md
Normal 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.
|
||||
29
packages/foundation/data/bytes/src/uint8/uint8.schemas.ts
Normal file
29
packages/foundation/data/bytes/src/uint8/uint8.schemas.ts
Normal 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,
|
||||
});
|
||||
238
packages/foundation/data/bytes/src/uint8/uint8.test.ts
Normal file
238
packages/foundation/data/bytes/src/uint8/uint8.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
195
packages/foundation/data/bytes/src/uint8/uint8.ts
Normal file
195
packages/foundation/data/bytes/src/uint8/uint8.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
2
packages/foundation/data/bytes/src/uuid/index.ts
Normal file
2
packages/foundation/data/bytes/src/uuid/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { generateUuid, uint8ArrayToUuid, uuidToUint8Array } from "./uuid.js";
|
||||
export { UuidStringSchema } from "./uuid.schemas.js";
|
||||
97
packages/foundation/data/bytes/src/uuid/uuid.md
Normal file
97
packages/foundation/data/bytes/src/uuid/uuid.md
Normal 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.
|
||||
15
packages/foundation/data/bytes/src/uuid/uuid.schemas.ts
Normal file
15
packages/foundation/data/bytes/src/uuid/uuid.schemas.ts
Normal 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"
|
||||
);
|
||||
70
packages/foundation/data/bytes/src/uuid/uuid.test.ts
Normal file
70
packages/foundation/data/bytes/src/uuid/uuid.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
packages/foundation/data/bytes/src/uuid/uuid.ts
Normal file
87
packages/foundation/data/bytes/src/uuid/uuid.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
8
packages/foundation/data/bytes/tests/setup/polyfills.ts
Normal file
8
packages/foundation/data/bytes/tests/setup/polyfills.ts
Normal 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;
|
||||
}
|
||||
12
packages/foundation/data/bytes/tsconfig.lib.json
Normal file
12
packages/foundation/data/bytes/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
37
packages/foundation/data/bytes/vite.config.ts
Normal file
37
packages/foundation/data/bytes/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/foundation/data/bytes/vitest.config.ts
Normal file
33
packages/foundation/data/bytes/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
92
packages/foundation/data/clipboard/README.md
Normal file
92
packages/foundation/data/clipboard/README.md
Normal 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
|
||||
|
||||
110
packages/foundation/data/clipboard/package.json
Normal file
110
packages/foundation/data/clipboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
126
packages/foundation/data/clipboard/src/errors/errors.md
Normal file
126
packages/foundation/data/clipboard/src/errors/errors.md
Normal 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.
|
||||
101
packages/foundation/data/clipboard/src/errors/errors.test.ts
Normal file
101
packages/foundation/data/clipboard/src/errors/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
133
packages/foundation/data/clipboard/src/errors/errors.ts
Normal file
133
packages/foundation/data/clipboard/src/errors/errors.ts
Normal 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 });
|
||||
}
|
||||
8
packages/foundation/data/clipboard/src/errors/index.ts
Normal file
8
packages/foundation/data/clipboard/src/errors/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
CLIPBOARD_ERROR_METADATA,
|
||||
type ClipboardCodeMetadata,
|
||||
ClipboardError,
|
||||
type ClipboardErrorCode,
|
||||
} from "./errors.js";
|
||||
94
packages/foundation/data/clipboard/src/fallback/fallback.md
Normal file
94
packages/foundation/data/clipboard/src/fallback/fallback.md
Normal 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.
|
||||
208
packages/foundation/data/clipboard/src/fallback/fallback.test.ts
Normal file
208
packages/foundation/data/clipboard/src/fallback/fallback.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
175
packages/foundation/data/clipboard/src/fallback/fallback.ts
Normal file
175
packages/foundation/data/clipboard/src/fallback/fallback.ts
Normal 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;
|
||||
}
|
||||
5
packages/foundation/data/clipboard/src/fallback/index.ts
Normal file
5
packages/foundation/data/clipboard/src/fallback/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
copyTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
readTextFallback,
|
||||
} from "./fallback.js";
|
||||
96
packages/foundation/data/clipboard/src/index.test.ts
Normal file
96
packages/foundation/data/clipboard/src/index.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
69
packages/foundation/data/clipboard/src/index.ts
Normal file
69
packages/foundation/data/clipboard/src/index.ts
Normal 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";
|
||||
96
packages/foundation/data/clipboard/src/manager/helpers.md
Normal file
96
packages/foundation/data/clipboard/src/manager/helpers.md
Normal 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.
|
||||
177
packages/foundation/data/clipboard/src/manager/helpers.test.ts
Normal file
177
packages/foundation/data/clipboard/src/manager/helpers.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
119
packages/foundation/data/clipboard/src/manager/helpers.ts
Normal file
119
packages/foundation/data/clipboard/src/manager/helpers.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
1
packages/foundation/data/clipboard/src/manager/index.ts
Normal file
1
packages/foundation/data/clipboard/src/manager/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClipboardManager } from "./manager.js";
|
||||
142
packages/foundation/data/clipboard/src/manager/manager.md
Normal file
142
packages/foundation/data/clipboard/src/manager/manager.md
Normal 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.
|
||||
397
packages/foundation/data/clipboard/src/manager/manager.test.ts
Normal file
397
packages/foundation/data/clipboard/src/manager/manager.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
144
packages/foundation/data/clipboard/src/manager/manager.ts
Normal file
144
packages/foundation/data/clipboard/src/manager/manager.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/foundation/data/clipboard/src/manager/operations.md
Normal file
113
packages/foundation/data/clipboard/src/manager/operations.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
228
packages/foundation/data/clipboard/src/manager/operations.ts
Normal file
228
packages/foundation/data/clipboard/src/manager/operations.ts
Normal 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" };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createClipboardObservability } from "./observability.js";
|
||||
export type {
|
||||
ClipboardCounters,
|
||||
ClipboardHistograms,
|
||||
ClipboardObservability,
|
||||
ClipboardObservabilityOptions,
|
||||
} from "./observability.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1 @@
|
||||
export { checkPermissions, onPermissionChange } from "./permissions.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
1
packages/foundation/data/clipboard/src/queue/index.ts
Normal file
1
packages/foundation/data/clipboard/src/queue/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AsyncQueue } from "./queue.js";
|
||||
96
packages/foundation/data/clipboard/src/queue/queue.md
Normal file
96
packages/foundation/data/clipboard/src/queue/queue.md
Normal 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).
|
||||
74
packages/foundation/data/clipboard/src/queue/queue.test.ts
Normal file
74
packages/foundation/data/clipboard/src/queue/queue.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
44
packages/foundation/data/clipboard/src/queue/queue.ts
Normal file
44
packages/foundation/data/clipboard/src/queue/queue.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createTagAllowlistSanitizer,
|
||||
identitySanitizer,
|
||||
} from "./sanitizer.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 : ""
|
||||
);
|
||||
}
|
||||
5
packages/foundation/data/clipboard/src/security/index.ts
Normal file
5
packages/foundation/data/clipboard/src/security/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isSecure,
|
||||
requireSecureContext,
|
||||
waitForNextFocus,
|
||||
} from "./security.js";
|
||||
105
packages/foundation/data/clipboard/src/security/security.md
Normal file
105
packages/foundation/data/clipboard/src/security/security.md
Normal 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.
|
||||
158
packages/foundation/data/clipboard/src/security/security.test.ts
Normal file
158
packages/foundation/data/clipboard/src/security/security.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
81
packages/foundation/data/clipboard/src/security/security.ts
Normal file
81
packages/foundation/data/clipboard/src/security/security.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
149
packages/foundation/data/clipboard/src/types/clipboard.types.ts
Normal file
149
packages/foundation/data/clipboard/src/types/clipboard.types.ts
Normal 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,
|
||||
});
|
||||
12
packages/foundation/data/clipboard/tsconfig.lib.json
Normal file
12
packages/foundation/data/clipboard/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
52
packages/foundation/data/clipboard/vite.config.ts
Normal file
52
packages/foundation/data/clipboard/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
7
packages/foundation/data/clipboard/vitest.config.ts
Normal file
7
packages/foundation/data/clipboard/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
environment: "happy-dom",
|
||||
fileParallelism: false,
|
||||
testTimeout: 5000,
|
||||
});
|
||||
128
packages/foundation/data/data-masking/README.md
Normal file
128
packages/foundation/data/data-masking/README.md
Normal 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
|
||||
100
packages/foundation/data/data-masking/package.json
Normal file
100
packages/foundation/data/data-masking/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
packages/foundation/data/data-masking/src/core/index.ts
Normal file
1
packages/foundation/data/data-masking/src/core/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { maskObject, maskString, maskValue } from "./masker";
|
||||
@@ -0,0 +1 @@
|
||||
export { maskObject, maskString, maskValue } from "./object-masker";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DATA_MASKING_CODES,
|
||||
type DataMaskingCode,
|
||||
DataMaskingError,
|
||||
} from "./data-masking-error";
|
||||
34
packages/foundation/data/data-masking/src/index.ts
Normal file
34
packages/foundation/data/data-masking/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type MaskOptionsInput,
|
||||
MaskOptionsSchema,
|
||||
type MaskRuleInput,
|
||||
MaskRuleSchema,
|
||||
type MaskStrategyInput,
|
||||
MaskStrategySchema,
|
||||
} from "./mask-rule.schema";
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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***@***");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
67
packages/foundation/data/data-masking/src/strategies/full.md
Normal file
67
packages/foundation/data/data-masking/src/strategies/full.md
Normal 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.
|
||||
@@ -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
Reference in New Issue
Block a user