Initial Commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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