first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.