first commit
This commit is contained in:
92
docs/foundation/data/clipboard/README.md
Normal file
92
docs/foundation/data/clipboard/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/clipboard - Enterprise Clipboard API abstraction: typed errors, OTel metrics, async queue serializer, secure-context guard, image/blob copy + read, paste-event read fallback, permission change subscription, focus-aware retry, sanitizer hook. Browser-tier, zero @suaveplan/* runtime deps beyond error + opentelemetry + types.
|
||||
|
||||
Enterprise Clipboard API abstraction for browser-tier consumers. Wraps the
|
||||
async Clipboard API with a textarea + `execCommand` fallback, a paste-event
|
||||
read fallback, image / blob copy + read via `ClipboardItem`, FIFO
|
||||
serialization, secure-context guard, permission change subscription,
|
||||
focus-aware retry, optional HTML sanitizer, structured errors via
|
||||
`@suaveplan/error`, and OpenTelemetry instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [errors](./src/errors/errors.md) — Stable error code registry, per-code metadata, and the concrete `ClipboardError` class for the `@suaveplan/clipboard` p…
|
||||
- [errors](./src/errors/errors.md) — Stable error code registry, per-code metadata, and the concrete `ClipboardError` class for the `@suaveplan/clipboard` p…
|
||||
- [fallback](./src/fallback/fallback.md) — Legacy clipboard fallback paths used when `navigator.clipboard` is unavailable or rejects. Provides a hidden-textarea +…
|
||||
- [fallback](./src/fallback/fallback.md) — Legacy clipboard fallback paths used when `navigator.clipboard` is unavailable or rejects. Provides a hidden-textarea +…
|
||||
- [manager](./src/manager/manager.md) — `ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`. It composes the navigator + window seams, the…
|
||||
- [helpers](./src/manager/helpers.md) — Pure helpers shared between `manager.ts` and `operations.ts`. Keeps both files under the line budget by housing the nav…
|
||||
- [manager](./src/manager/manager.md) — `ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`. It composes the navigator + window seams, the…
|
||||
- [operations](./src/manager/operations.md) — Pure operation functions invoked by `ClipboardManager`. Each function takes an `OperationContext` (navigator, window, o…
|
||||
- [observability](./src/observability/observability.md) — OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires four counters and one histogram on a single `Met…
|
||||
- [observability](./src/observability/observability.md) — OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires four counters and one histogram on a single `Met…
|
||||
- [permissions](./src/permissions/permissions.md) — Probe and subscribe to clipboard permission state via the browser's Permissions API. `checkPermissions` returns a snaps…
|
||||
- [permissions](./src/permissions/permissions.md) — Probe and subscribe to clipboard permission state via the browser's Permissions API. `checkPermissions` returns a snaps…
|
||||
- [queue](./src/queue/queue.md) — `AsyncQueue` is a minimal FIFO asynchronous queue that serializes operations on a single tail promise. The `ClipboardMa…
|
||||
- [queue](./src/queue/queue.md) — `AsyncQueue` is a minimal FIFO asynchronous queue that serializes operations on a single tail promise. The `ClipboardMa…
|
||||
- [sanitizer](./src/sanitizer/sanitizer.md) — Sanitizer hooks applied to inbound `text/html` clipboard payloads. The package ships an identity sanitizer (pass-throug…
|
||||
- [sanitizer](./src/sanitizer/sanitizer.md) — Sanitizer hooks applied to inbound `text/html` clipboard payloads. The package ships an identity sanitizer (pass-throug…
|
||||
- [security](./src/security/security.md) — Browser-side security helpers that guard clipboard operations against insecure contexts and orchestrate the focus-aware…
|
||||
- [security](./src/security/security.md) — Browser-side security helpers that guard clipboard operations against insecure contexts and orchestrate the focus-aware…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/clipboard
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ClipboardManager } from "@suaveplan/clipboard";
|
||||
|
||||
const clipboard = new ClipboardManager({
|
||||
useFallback: true,
|
||||
requireSecureContext: false,
|
||||
serialize: true,
|
||||
});
|
||||
|
||||
const result = await clipboard.copyText("hello");
|
||||
if (!result.success) {
|
||||
console.error("copy failed:", result.code, result.error);
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`manager/manager.md`](./src/manager/manager.md) — `ClipboardManager`, the orchestrator.
|
||||
- [`manager/helpers.md`](./src/manager/helpers.md) — internal helpers used by the manager.
|
||||
- [`manager/operations.md`](./src/manager/operations.md) — pure operation functions.
|
||||
- [`errors/errors.md`](./src/errors/errors.md) — `CLIPBOARD_CODES`, `ClipboardError`, metadata.
|
||||
- [`fallback/fallback.md`](./src/fallback/fallback.md) — textarea + paste-event fallback paths.
|
||||
- [`observability/observability.md`](./src/observability/observability.md) — OpenTelemetry counters + histogram.
|
||||
- [`permissions/permissions.md`](./src/permissions/permissions.md) — permission probe + change subscription.
|
||||
- [`queue/queue.md`](./src/queue/queue.md) — `AsyncQueue` FIFO serializer.
|
||||
- [`sanitizer/sanitizer.md`](./src/sanitizer/sanitizer.md) — sanitizer interface + helpers.
|
||||
- [`security/security.md`](./src/security/security.md) — secure-context guards + focus utilities.
|
||||
|
||||
## Features
|
||||
|
||||
- Copy plain text, HTML, images, and arbitrary MIME blobs.
|
||||
- Read plain text and rich content with optional HTML sanitization.
|
||||
- Paste-event fallback when `navigator.clipboard.readText` is unavailable.
|
||||
- Permission probe + live subscription (`onPermissionChange`).
|
||||
- Async FIFO queue so concurrent calls execute in arrival order.
|
||||
- Secure-context guard with typed error code (`CLIPBOARD.INSECURE_CONTEXT`).
|
||||
- Focus-aware retry on `NotAllowedError: Document is not focused`.
|
||||
- Eight stable error codes under the `CLIPBOARD.*` namespace.
|
||||
- Five OpenTelemetry instruments wired into every public method.
|
||||
- SSR-safe — graceful degradation when `document` / `navigator` / `window` are undefined.
|
||||
|
||||
## Error codes
|
||||
|
||||
`CLIPBOARD.API_UNAVAILABLE`, `CLIPBOARD.PERMISSION_DENIED`,
|
||||
`CLIPBOARD.FALLBACK_FAILED`, `CLIPBOARD.INSECURE_CONTEXT`,
|
||||
`CLIPBOARD.NO_CONTENT`, `CLIPBOARD.READ_FAILED`,
|
||||
`CLIPBOARD.DOCUMENT_UNAVAILABLE`, `CLIPBOARD.QUEUE_DRAINED`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
126
docs/foundation/data/clipboard/src/errors/errors.md
Normal file
126
docs/foundation/data/clipboard/src/errors/errors.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# `errors/errors.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error code registry, per-code metadata, and the concrete
|
||||
`ClipboardError` class for the `@suaveplan/clipboard` package. Every error
|
||||
path inside the manager, fallback, permissions, and security modules
|
||||
surfaces one of these codes so consumers can match programmatically
|
||||
without inspecting message strings. Codes are namespaced under
|
||||
`CLIPBOARD.*` and registered via the shared `defineCodes` helper from
|
||||
`@suaveplan/error` so the runtime values, the literal union type, and the
|
||||
metadata table stay in lock-step.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable codes: `API_UNAVAILABLE`, `PERMISSION_DENIED`,
|
||||
`FALLBACK_FAILED`, `INSECURE_CONTEXT`, `NO_CONTENT`, `READ_FAILED`,
|
||||
`DOCUMENT_UNAVAILABLE`, `QUEUE_DRAINED`.
|
||||
- Per-code metadata: `httpStatus`, `retryable`, `severity`, `description`.
|
||||
- `ClipboardError extends SuaveplanError<ClipboardErrorCode>` — preserves
|
||||
cause chains, supports OTel span events, and integrates with the
|
||||
project's typed-error contract.
|
||||
- `asClipboardError(code, cause, fallbackMessage)` lifts unknown thrown
|
||||
values into the typed hierarchy at every catch site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLIPBOARD_CODES,
|
||||
ClipboardError,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
try {
|
||||
// ...
|
||||
} catch (cause) {
|
||||
throw new ClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
"Clipboard read rejected",
|
||||
{ cause: cause instanceof Error ? cause : new Error(String(cause)) },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
CLIPBOARD_ERROR_METADATA,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
function decide(err: unknown): { retry: boolean; status: number } {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
typeof (err as { code?: string }).code === "string" &&
|
||||
(err as { code: string }).code.startsWith("CLIPBOARD.")
|
||||
) {
|
||||
const meta =
|
||||
CLIPBOARD_ERROR_METADATA[
|
||||
(err as { code: keyof typeof CLIPBOARD_ERROR_METADATA })
|
||||
.code
|
||||
];
|
||||
return { retry: meta.retryable, status: meta.httpStatus };
|
||||
}
|
||||
const lifted = asClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
err,
|
||||
"Unknown clipboard failure.",
|
||||
);
|
||||
return {
|
||||
retry: lifted.metadata.retryable,
|
||||
status: lifted.metadata.httpStatus,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CLIPBOARD_CODES`
|
||||
|
||||
`Readonly<Record<<key>, "CLIPBOARD.<key>">>` produced by `defineCodes`.
|
||||
The runtime values are namespaced strings; the type infers a literal
|
||||
union for exhaustive matching.
|
||||
|
||||
### `CLIPBOARD_ERROR_METADATA`
|
||||
|
||||
`Readonly<Record<ClipboardErrorCode, ClipboardCodeMetadata>>` — frozen map
|
||||
from each code to its `httpStatus`, `retryable`, `severity`, and
|
||||
`description` fields.
|
||||
|
||||
### `ClipboardCodeMetadata`
|
||||
|
||||
Interface with `httpStatus: number`, `severity: "info" | "warning" | "error"`,
|
||||
`retryable: boolean`, `description: string`.
|
||||
|
||||
### `ClipboardError`
|
||||
|
||||
Concrete subclass of `SuaveplanError<ClipboardErrorCode>`. Constructor
|
||||
signature: `new ClipboardError(code, message, options?)`. Exposes a
|
||||
`.metadata` getter that returns the row from
|
||||
`CLIPBOARD_ERROR_METADATA`.
|
||||
|
||||
### `asClipboardError(code, cause, fallbackMessage)`
|
||||
|
||||
Lifts an arbitrary `unknown` thrown value into a `ClipboardError`,
|
||||
preserving the cause chain. Uses `cause.message` when available and
|
||||
`fallbackMessage` otherwise. Always returns a `ClipboardError` with the
|
||||
supplied code.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package version of `defineCodes` is the canonical helper from
|
||||
`@suaveplan/error`; every package uses the same pattern so cross-
|
||||
cutting tooling (registry validators, retention checks, OpenSpec
|
||||
conformance) can introspect every namespace identically. Codes are
|
||||
intentionally namespaced strings rather than enums so they remain JSON-
|
||||
serialisable and stable across versions. The metadata table is `Object.freeze`-d
|
||||
so mutation is a typecheck error and a runtime error; reviewers can
|
||||
diff additions in pull requests without worrying about silent rewrites.
|
||||
94
docs/foundation/data/clipboard/src/fallback/fallback.md
Normal file
94
docs/foundation/data/clipboard/src/fallback/fallback.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `fallback/fallback.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Legacy clipboard fallback paths used when `navigator.clipboard` is
|
||||
unavailable or rejects. Provides a hidden-textarea + `execCommand("copy")`
|
||||
write path and a paste-event-based read path. Both return the same
|
||||
`ClipboardResult` shape as the API path so the manager's call sites do
|
||||
not branch on which mechanism actually ran. The paste-event fallback is
|
||||
purpose-built for environments — older Safari, embedded WebViews, and
|
||||
permission-denied contexts — where `navigator.clipboard.readText`
|
||||
rejects but the user can paste manually.
|
||||
|
||||
## Features
|
||||
|
||||
- `copyTextFallback(text)` — synchronous textarea + `execCommand` copy.
|
||||
- `readTextFallback(timeoutMs)` — focus + paste-event capture with a
|
||||
configurable timeout.
|
||||
- `dispatchSyntheticPaste(text)` — test helper that fires a synthetic
|
||||
`paste` event on the most recent hidden host.
|
||||
- SSR-safe — both copy and read paths return
|
||||
`CLIPBOARD.DOCUMENT_UNAVAILABLE` when `document` is undefined.
|
||||
- Off-screen, non-interactive host element so users never see the
|
||||
textarea visibly steal focus.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { copyTextFallback } from "@suaveplan/clipboard";
|
||||
|
||||
const result = copyTextFallback("hello");
|
||||
if (!result.success) {
|
||||
console.warn("legacy copy failed:", result.code);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
readTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
async function readClipboardWithPasteFallback(): Promise<string | undefined> {
|
||||
const result = await readTextFallback(2000);
|
||||
return result.success ? result.data : undefined;
|
||||
}
|
||||
|
||||
// Inside a test harness:
|
||||
const promise = readTextFallback(500);
|
||||
queueMicrotask(() => dispatchSyntheticPaste("from-test"));
|
||||
const result = await promise;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `copyTextFallback(text: string): ClipboardResult<string>`
|
||||
|
||||
Stages a hidden `<textarea>` carrying `text`, selects it, runs
|
||||
`document.execCommand("copy")`, and removes the textarea on cleanup.
|
||||
Returns `{ success: true, data, method: "fallback" }` on success and a
|
||||
structured failure with `code: CLIPBOARD.FALLBACK_FAILED` (or
|
||||
`CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser) otherwise.
|
||||
|
||||
### `readTextFallback(timeoutMs: number): Promise<ClipboardResult<string>>`
|
||||
|
||||
Creates a hidden textarea, focuses it, listens for the first `paste`
|
||||
event, and resolves with the `text/plain` payload carried by the event.
|
||||
Resolves with `code: CLIPBOARD.READ_FAILED` if the timeout elapses
|
||||
first and `code: CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser.
|
||||
|
||||
### `dispatchSyntheticPaste(text: string): boolean`
|
||||
|
||||
Test helper. Locates the most recent hidden host (`textarea[aria-hidden="true"]`)
|
||||
and dispatches a `paste` ClipboardEvent carrying `text` as `text/plain`.
|
||||
Returns `true` when a host was found, `false` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hidden textarea is positioned at `-9999px` and given `opacity: 0`
|
||||
plus `aria-hidden="true"` so assistive technologies skip it. The cleanup
|
||||
runs in a `finally` block — even if `execCommand` throws, the textarea is
|
||||
removed. The read fallback tracks a `settled` flag so a duplicate or
|
||||
post-timeout `paste` event cannot resolve the promise twice. The paste
|
||||
event may carry a null `clipboardData` on some browsers; the module
|
||||
falls back to an empty string in that case rather than rejecting, since
|
||||
"clipboard contained nothing" is a valid outcome. The synthetic-paste
|
||||
helper is exported to keep tests deterministic — production code never
|
||||
calls it.
|
||||
96
docs/foundation/data/clipboard/src/manager/helpers.md
Normal file
96
docs/foundation/data/clipboard/src/manager/helpers.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `manager/helpers.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers shared between `manager.ts` and `operations.ts`. Keeps both
|
||||
files under the line budget by housing the navigator / window resolution,
|
||||
the structured failure builder, the focus-error detector, and the
|
||||
`ClipboardItem` build + read pipelines in a single, side-effect-free
|
||||
module that tests can exercise directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveNavigator(nav?)` — caller, global, or empty shim.
|
||||
- `resolveWindow(win?)` — caller, global, or `undefined`.
|
||||
- `failure(code, message, method)` — typed `ClipboardResult` failure.
|
||||
- `isFocusError(cause)` — detects the canonical focus-required rejection.
|
||||
- `buildClipboardItems(data)` — MIME → `Blob` map for `ClipboardItem`.
|
||||
- `readClipboardItems(items, sanitizer?)` — pulls text / html / image /
|
||||
blobs out of a list of `ClipboardItem` instances.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { failure, CLIPBOARD_CODES } from "@suaveplan/clipboard";
|
||||
|
||||
const r = failure(CLIPBOARD_CODES.NO_CONTENT, "empty", "api");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildClipboardItems,
|
||||
readClipboardItems,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const items = buildClipboardItems({
|
||||
text: "hello",
|
||||
html: "<b>hi</b>",
|
||||
image: pngBlob,
|
||||
blobs: { "application/json": jsonBlob },
|
||||
});
|
||||
|
||||
const clipboardItem = new ClipboardItem(items);
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
|
||||
// Round-trip back:
|
||||
const read = await navigator.clipboard.read();
|
||||
const data = await readClipboardItems(read, (html) => html.trim());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `resolveNavigator(nav?: ClipboardNavigator): ClipboardNavigator`
|
||||
|
||||
Returns `nav` when defined, otherwise the global `navigator`, otherwise
|
||||
the empty object `{}`.
|
||||
|
||||
### `resolveWindow(win?: ClipboardWindowLike): ClipboardWindowLike | undefined`
|
||||
|
||||
Returns `win` when defined, otherwise the global `window`, otherwise
|
||||
`undefined`.
|
||||
|
||||
### `failure<T>(code, message, method): ClipboardResult<T>`
|
||||
|
||||
Returns `{ success: false, error: message, code, method }`. Generic over
|
||||
`T` so call sites compose with their own result types.
|
||||
|
||||
### `isFocusError(cause: unknown): boolean`
|
||||
|
||||
Returns `true` when `cause` is an `Error` whose `name === "NotAllowedError"`
|
||||
and whose `message` (case-insensitively) contains `"focus"`. The browser
|
||||
signals "Clipboard needs page focus" with this exact shape.
|
||||
|
||||
### `buildClipboardItems(data: ClipboardData): Record<string, Blob>`
|
||||
|
||||
Builds a MIME → Blob map for `new ClipboardItem(...)`. Skips empty
|
||||
`text` and `html`. Defaults a typeless image blob to `image/png`.
|
||||
|
||||
### `readClipboardItems(items, sanitizer?): Promise<ClipboardData>`
|
||||
|
||||
Iterates every entry of every `ClipboardItem`, populating `text`,
|
||||
`html`, `image`, and `blobs`. The first image-typed entry wins. The
|
||||
`sanitizer` (when supplied) is applied to the raw `text/html` payload
|
||||
before returning.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers are intentionally side-effect-free and free of telemetry
|
||||
calls so the same code runs from `runCopyText`, `runCopyRich`, etc.,
|
||||
without double-recording counters. `buildClipboardItems` and
|
||||
`readClipboardItems` are mirror images of each other — every MIME slot
|
||||
that `buildClipboardItems` writes is read back by `readClipboardItems`
|
||||
into the same `ClipboardData` shape. The focus-error detector matches
|
||||
both Chromium ("Document is not focused.") and WebKit ("Document not
|
||||
focused.") wording.
|
||||
142
docs/foundation/data/clipboard/src/manager/manager.md
Normal file
142
docs/foundation/data/clipboard/src/manager/manager.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# `manager/manager.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`.
|
||||
It composes the navigator + window seams, the FIFO `AsyncQueue`, the
|
||||
typed error registry, the OpenTelemetry observability bundle, the
|
||||
secure-context guard, the permission probe, and the fallback path into a
|
||||
single ergonomic facade. Every public method records counters, the
|
||||
operation latency histogram, and per-code error totals so production
|
||||
deployments observe clipboard health without any caller-side wiring.
|
||||
|
||||
## Features
|
||||
|
||||
- `copyText`, `copyRichContent`, `readText`, `readRichContent` with
|
||||
consistent `ClipboardResult` returns.
|
||||
- `checkPermissions` + `onPermissionChange` for permission lifecycle.
|
||||
- `isSupported`, `isSecure`, `dispose` for environment + lifecycle.
|
||||
- Configurable serialization (`serialize: true` by default).
|
||||
- Configurable secure-context guard (`requireSecureContext`).
|
||||
- Optional sanitizer hook applied to inbound HTML.
|
||||
- Optional pre-built observability bundle.
|
||||
- Focus-aware retry on `NotAllowedError: Document is not focused`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ClipboardManager } from "@suaveplan/clipboard";
|
||||
|
||||
const clipboard = new ClipboardManager();
|
||||
const result = await clipboard.copyText("hello");
|
||||
if (!result.success) {
|
||||
console.warn("copy failed:", result.code, result.error);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ClipboardManager,
|
||||
createClipboardObservability,
|
||||
createTagAllowlistSanitizer,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const observability = createClipboardObservability();
|
||||
const clipboard = new ClipboardManager({
|
||||
useFallback: true,
|
||||
requireSecureContext: true,
|
||||
serialize: true,
|
||||
focusRetry: true,
|
||||
sanitizer: createTagAllowlistSanitizer(["p", "b", "i", "br"]),
|
||||
pasteTimeoutMs: 2000,
|
||||
observability,
|
||||
});
|
||||
|
||||
const png = await loadPngBlob();
|
||||
await clipboard.copyRichContent({
|
||||
text: "fallback text",
|
||||
html: "<b>bold</b>",
|
||||
image: png,
|
||||
});
|
||||
|
||||
const unsub = await clipboard.onPermissionChange((snapshot, name) => {
|
||||
console.info("permission change", name, snapshot);
|
||||
});
|
||||
|
||||
// later: unsub(); await clipboard.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new ClipboardManager(options?, nav?, win?)`
|
||||
|
||||
Construct a manager. `options` is merged on top of
|
||||
`DEFAULT_CLIPBOARD_OPTIONS`. `nav` and `win` default to the global
|
||||
`navigator` and `window`; either may be supplied as a structural shape
|
||||
for SSR or unit tests.
|
||||
|
||||
### `copyText(text): Promise<ClipboardResult<string>>`
|
||||
|
||||
Copy plain text. Uses the async API when available; falls back to the
|
||||
hidden-textarea path on rejection or absence when `useFallback` is true.
|
||||
Short-circuits with `CLIPBOARD.INSECURE_CONTEXT` when
|
||||
`requireSecureContext` is set and the bound window is insecure.
|
||||
|
||||
### `copyRichContent(data): Promise<ClipboardResult<ClipboardData>>`
|
||||
|
||||
Copy rich content (text / html / image / blobs) via `ClipboardItem`. The
|
||||
fallback path requires `data.text`; rich-only payloads without text
|
||||
short-circuit with `CLIPBOARD.NO_CONTENT`. Empty payloads also surface
|
||||
`CLIPBOARD.NO_CONTENT`.
|
||||
|
||||
### `readText(): Promise<ClipboardResult<string>>`
|
||||
|
||||
Read plain text. Falls back to the paste-event capture when the API is
|
||||
unavailable or rejects (and `useFallback` is true).
|
||||
|
||||
### `readRichContent(): Promise<ClipboardResult<ClipboardData>>`
|
||||
|
||||
Read rich content. Returns `CLIPBOARD.API_UNAVAILABLE` when no async API
|
||||
is present (rich content has no usable fallback). Applies the optional
|
||||
sanitizer to inbound HTML.
|
||||
|
||||
### `checkPermissions(): Promise<PermissionResult>`
|
||||
|
||||
Snapshot of `clipboard-read` + `clipboard-write` permission state.
|
||||
|
||||
### `onPermissionChange(cb): Promise<() => void>`
|
||||
|
||||
Subscribe to permission changes. Returns an unsubscribe function.
|
||||
|
||||
### `isSupported(): boolean`
|
||||
|
||||
True when `navigator.clipboard` is defined on the bound navigator.
|
||||
|
||||
### `isSecure(): boolean`
|
||||
|
||||
True when the bound window does not flag `isSecureContext: false`.
|
||||
|
||||
### `dispose(): Promise<void>`
|
||||
|
||||
Drain the internal queue. After dispose, queued operations resolve with
|
||||
their current outcomes; new enqueues reject.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager keeps a single `OperationContext` reference (navigator,
|
||||
window, options, observability) shared with the pure operation functions
|
||||
in `operations.ts`. This split keeps the class under the 300-line file
|
||||
budget while preserving the public API. The internal `run` method wraps
|
||||
every operation in a latency-measuring closure and either enqueues it
|
||||
via the `AsyncQueue` (when `serialize: true`) or invokes it directly. The
|
||||
`recordResult` helper updates counters per-call and per-error-code so
|
||||
the OTel pipeline measures both the macro-shape (copy vs. read) and the
|
||||
micro-shape (which `CLIPBOARD.*` code fired). The constructor is SSR-
|
||||
safe: missing globals collapse to a fully-typed empty navigator and an
|
||||
undefined window so import-time evaluation never throws on a server.
|
||||
113
docs/foundation/data/clipboard/src/manager/operations.md
Normal file
113
docs/foundation/data/clipboard/src/manager/operations.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# `manager/operations.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure operation functions invoked by `ClipboardManager`. Each function
|
||||
takes an `OperationContext` (navigator, window, options, observability)
|
||||
plus the operation-specific arguments and returns a `ClipboardResult`.
|
||||
Splitting these out of the class keeps `manager.ts` under the file-size
|
||||
budget and makes each path independently testable without instantiating a
|
||||
full manager.
|
||||
|
||||
## Features
|
||||
|
||||
- `runCopyText(ctx, text)` — plain-text copy with secure-context gate,
|
||||
API path, fallback path, and focus-aware retry.
|
||||
- `runCopyRich(ctx, data)` — rich-content copy via `ClipboardItem` with
|
||||
fallback-to-text behaviour and `CLIPBOARD.NO_CONTENT` guards.
|
||||
- `runReadText(ctx)` — plain-text read with paste-event fallback.
|
||||
- `runReadRich(ctx)` — rich-content read with sanitizer pass-through.
|
||||
- Internal `tryApi` wraps every navigator call in a focus-retry-capable
|
||||
envelope.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
runCopyText,
|
||||
type OperationContext,
|
||||
createClipboardObservability,
|
||||
DEFAULT_CLIPBOARD_OPTIONS,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const ctx: OperationContext = {
|
||||
nav: navigator,
|
||||
win: window,
|
||||
options: DEFAULT_CLIPBOARD_OPTIONS,
|
||||
obs: createClipboardObservability(),
|
||||
};
|
||||
const result = await runCopyText(ctx, "hello");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runCopyRich,
|
||||
type OperationContext,
|
||||
DEFAULT_CLIPBOARD_OPTIONS,
|
||||
createClipboardObservability,
|
||||
createTagAllowlistSanitizer,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const ctx: OperationContext = {
|
||||
nav: navigator,
|
||||
win: window,
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
sanitizer: createTagAllowlistSanitizer(["p", "b", "i"]),
|
||||
requireSecureContext: true,
|
||||
},
|
||||
obs: createClipboardObservability(),
|
||||
};
|
||||
|
||||
const result = await runCopyRich(ctx, {
|
||||
text: "fallback text",
|
||||
html: "<b>x</b>",
|
||||
image: await fetch("/logo.png").then((r) => r.blob()),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OperationContext`
|
||||
|
||||
`{ nav, win, options, obs }`. Lifetime-bound to the manager.
|
||||
|
||||
### `runCopyText(ctx, text)`
|
||||
|
||||
Returns `Promise<ClipboardResult<string>>`. Honours
|
||||
`requireSecureContext`, `useFallback`, `focusRetry`, and the fallback
|
||||
counter wiring.
|
||||
|
||||
### `runCopyRich(ctx, data)`
|
||||
|
||||
Returns `Promise<ClipboardResult<ClipboardData>>`. Builds a single
|
||||
`ClipboardItem` covering every populated MIME slot. Falls back to
|
||||
`runCopyText` when the API rejects and `data.text` is non-empty.
|
||||
|
||||
### `runReadText(ctx)`
|
||||
|
||||
Returns `Promise<ClipboardResult<string>>`. Uses `readTextFallback` from
|
||||
the `fallback/` module when the API is absent or rejects.
|
||||
|
||||
### `runReadRich(ctx)`
|
||||
|
||||
Returns `Promise<ClipboardResult<ClipboardData>>`. Walks every returned
|
||||
`ClipboardItem` and populates `text` / `html` / `image` / `blobs`. The
|
||||
first image wins when multiple are present. The optional sanitizer
|
||||
mutates `html` before returning.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The functions are exported (not module-private) so the test suite can
|
||||
exercise each branch in isolation without driving a full `ClipboardManager`.
|
||||
The `tryApi` helper centralises the focus-retry logic: a single
|
||||
`NotAllowedError` whose message mentions "focus" triggers one
|
||||
`waitForNextFocus` + retry. Any other rejection skips the retry path and
|
||||
lifts the cause via `asClipboardError(CLIPBOARD.READ_FAILED, ...)`. The
|
||||
fallback counter (`clipboard_fallback_used_total`) is incremented with a
|
||||
`reason` attribute (`"api_threw"`, `"no_api"`, `"fallback_failed"`) at the
|
||||
exact transition point so operators can distinguish "fallback because API
|
||||
returned no" from "fallback because API rejected" — a critical signal
|
||||
when diagnosing partial outages.
|
||||
@@ -0,0 +1,95 @@
|
||||
# `observability/observability.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires
|
||||
four counters and one histogram on a single `Meter` plus a `Tracer`,
|
||||
defaulting to the no-op providers from `@opentelemetry/api` when no
|
||||
real provider is supplied. The `ClipboardManager` records into every
|
||||
instrument across every public method so production deployments measure
|
||||
copy / read volume, fallback usage, error rate by code, and operation
|
||||
latency without any caller-side wiring beyond passing a `Meter`.
|
||||
|
||||
## Features
|
||||
|
||||
- `clipboard_copy_total{method, kind}` — counter, copy attempts.
|
||||
- `clipboard_read_total{method, kind}` — counter, read attempts.
|
||||
- `clipboard_fallback_used_total{reason}` — counter, fallback engagements.
|
||||
- `clipboard_error_total{code}` — counter, errors by `CLIPBOARD.*` code.
|
||||
- `clipboard_op_latency_ms{op, method}` — histogram, operation latency.
|
||||
- Single factory: `createClipboardObservability(opts?)`.
|
||||
- Caller may supply a pre-built bundle to skip instrument creation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ClipboardManager,
|
||||
createClipboardObservability,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const observability = createClipboardObservability();
|
||||
const clipboard = new ClipboardManager({ observability });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
ClipboardManager,
|
||||
createClipboardObservability,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const meter = metrics.getMeterProvider().getMeter("my-app", "1.0.0");
|
||||
const observability = createClipboardObservability({ meter });
|
||||
const clipboard = new ClipboardManager({ observability });
|
||||
|
||||
// Wire the meter into your OTel SDK exporter; counters fire on every
|
||||
// copy / read call without extra plumbing.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createClipboardObservability(opts?: ClipboardObservabilityOptions): ClipboardObservability`
|
||||
|
||||
Returns a bundle with the four counters and one histogram. Pass through
|
||||
the caller-supplied bundle if `opts.observability` is set; otherwise use
|
||||
the supplied `Meter` and `Tracer` (or the no-op global ones).
|
||||
|
||||
### `ClipboardObservability`
|
||||
|
||||
`{ meter, tracer, counters, histograms }` — strongly typed; counter and
|
||||
histogram entries are OpenTelemetry `Counter` and `Histogram` instances
|
||||
respectively, so consumers can call `.add(n, attrs)` and
|
||||
`.record(ms, attrs)` directly when composing higher-level metrics.
|
||||
|
||||
### `ClipboardCounters`
|
||||
|
||||
- `copyTotal` — fires on every `copyText` / `copyRichContent` call.
|
||||
- `readTotal` — fires on every `readText` / `readRichContent` call.
|
||||
- `fallbackUsedTotal` — fires whenever the fallback path runs.
|
||||
- `errorTotal` — fires once per failure, labeled by error code.
|
||||
|
||||
### `ClipboardHistograms`
|
||||
|
||||
- `opLatencyMs` — milliseconds spent inside each public method, labeled
|
||||
with the `op` name and the `method` (`api` / `fallback`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory delegates to `@suaveplan/opentelemetry`'s `createCounter` and
|
||||
`createHistogram` helpers so all instruments share the project's
|
||||
canonical OTel wiring (descriptions, units, naming convention). The
|
||||
`Meter` and `Tracer` names are pinned to the package name (`@suaveplan/clipboard`)
|
||||
so observers can filter to clipboard signals without inspecting
|
||||
attributes. When no provider is wired, `metrics.getMeter` returns the
|
||||
no-op meter from `@opentelemetry/api`, so calling `.add` / `.record` on
|
||||
the instruments is safe and cost-free. Tests assert on the instrument
|
||||
shape (callable `add` / `record`) plus the pass-through behaviour of
|
||||
`opts.observability` so consumers can swap implementations without
|
||||
forking the factory.
|
||||
@@ -0,0 +1,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.
|
||||
96
docs/foundation/data/clipboard/src/queue/queue.md
Normal file
96
docs/foundation/data/clipboard/src/queue/queue.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `queue/queue.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AsyncQueue` is a minimal FIFO asynchronous queue that serializes
|
||||
operations on a single tail promise. The `ClipboardManager` uses it to
|
||||
ensure concurrent `copyText` / `readText` / `copyRichContent` /
|
||||
`readRichContent` calls execute in arrival order — without it, the
|
||||
trailing call would win non-deterministically because the browser's
|
||||
Clipboard API does not provide ordering guarantees across overlapping
|
||||
requests. The queue is intentionally tiny (one promise reference plus a
|
||||
drained flag) so it adds no measurable overhead in the common single-call
|
||||
case.
|
||||
|
||||
## Features
|
||||
|
||||
- FIFO ordering — callbacks run strictly in arrival order.
|
||||
- Rejection-tolerant — a rejected operation does not stall later ones.
|
||||
- `drain()` closes the queue and resolves once the tail settles.
|
||||
- `reset()` re-opens a drained queue (useful for tests + dispose flows).
|
||||
- Zero dependencies; no `Promise.race`, no timers, no microtask leaks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AsyncQueue } from "@suaveplan/clipboard";
|
||||
|
||||
const queue = new AsyncQueue();
|
||||
const results = await Promise.all([
|
||||
queue.enqueue(async () => "a"),
|
||||
queue.enqueue(async () => "b"),
|
||||
queue.enqueue(async () => "c"),
|
||||
]);
|
||||
// results === ["a", "b", "c"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AsyncQueue } from "@suaveplan/clipboard";
|
||||
|
||||
const queue = new AsyncQueue();
|
||||
|
||||
// Rejection does not break the queue.
|
||||
queue.enqueue(async () => {
|
||||
throw new Error("boom");
|
||||
}).catch(() => undefined);
|
||||
const next = await queue.enqueue(async () => "still works");
|
||||
|
||||
// Close the queue when shutting down.
|
||||
await queue.drain();
|
||||
|
||||
// Re-open later for a new session.
|
||||
queue.reset();
|
||||
await queue.enqueue(async () => "round two");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AsyncQueue()`
|
||||
|
||||
Constructs a queue with an already-resolved tail.
|
||||
|
||||
### `queue.enqueue<T>(fn: () => Promise<T>): Promise<T>`
|
||||
|
||||
Append `fn` to the queue. The returned promise settles with `fn`'s
|
||||
outcome once every previously enqueued callback has settled. If the
|
||||
queue has been drained, returns a rejected promise carrying `"AsyncQueue:
|
||||
enqueue after drain"`.
|
||||
|
||||
### `queue.drain(): Promise<void>`
|
||||
|
||||
Closes the queue. Subsequent `enqueue` calls reject. Returns a promise
|
||||
that resolves once the current tail settles (success or failure).
|
||||
|
||||
### `queue.reset(): void`
|
||||
|
||||
Re-opens a drained queue. Replaces the tail with a fresh
|
||||
`Promise.resolve()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tail promise chains through `then(onFulfilled, onRejected)` with both
|
||||
arms re-invoking `fn`. This means an operation always runs after the
|
||||
previous one regardless of how the previous resolved — the rejection does
|
||||
not propagate into the queue's tracked tail (we explicitly catch via
|
||||
`.then(() => undefined, () => undefined)`). Because the tail is `unknown`-
|
||||
typed internally, the generic `T` parameter on `enqueue` is preserved end
|
||||
to end. The drained flag is checked synchronously inside `enqueue` so
|
||||
post-drain calls reject without scheduling work. The queue is private to
|
||||
the manager but exported so advanced consumers can reuse it for
|
||||
caller-defined ordering (e.g. when composing with other browser APIs).
|
||||
88
docs/foundation/data/clipboard/src/sanitizer/sanitizer.md
Normal file
88
docs/foundation/data/clipboard/src/sanitizer/sanitizer.md
Normal 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.
|
||||
105
docs/foundation/data/clipboard/src/security/security.md
Normal file
105
docs/foundation/data/clipboard/src/security/security.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `security/security.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser-side security helpers that guard clipboard operations against
|
||||
insecure contexts and orchestrate the focus-aware retry path. The
|
||||
Clipboard API is gated by the browser on `window.isSecureContext` — calls
|
||||
from non-HTTPS origins reject with `NotAllowedError`, and the spec
|
||||
explicitly forbids the API outside secure contexts. This module exposes a
|
||||
typed `requireSecureContext` guard plus an SSR-safe `isSecure` predicate
|
||||
so call sites surface `CLIPBOARD.INSECURE_CONTEXT` rather than a generic
|
||||
DOMException. The companion `waitForNextFocus` helper resolves on the next
|
||||
`focus` event (with a timeout) so the manager can recover from
|
||||
`NotAllowedError: Document is not focused`.
|
||||
|
||||
## Features
|
||||
|
||||
- `isSecure(window?)` — SSR-safe predicate. Returns `true` outside the
|
||||
browser so universal code paths short-circuit cleanly.
|
||||
- `requireSecureContext(window?)` — throws
|
||||
`ClipboardError(CLIPBOARD.INSECURE_CONTEXT)` when the window flags
|
||||
`isSecureContext === false`.
|
||||
- `waitForNextFocus(window?, timeoutMs)` — resolves `true` on the next
|
||||
`focus` event, `false` on timeout, `false` when the window cannot
|
||||
attach listeners.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireSecureContext } from "@suaveplan/clipboard";
|
||||
|
||||
requireSecureContext(); // throws CLIPBOARD.INSECURE_CONTEXT on HTTP
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLIPBOARD_CODES,
|
||||
ClipboardError,
|
||||
isSecure,
|
||||
waitForNextFocus,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
async function copyWithFocusRetry(text: string): Promise<void> {
|
||||
if (!isSecure()) {
|
||||
throw new ClipboardError(
|
||||
CLIPBOARD_CODES.INSECURE_CONTEXT,
|
||||
"Refusing clipboard write outside HTTPS.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.name === "NotAllowedError" &&
|
||||
err.message.includes("focus")
|
||||
) {
|
||||
const focused = await waitForNextFocus(window, 500);
|
||||
if (focused) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isSecure(window?: ClipboardWindowLike): boolean`
|
||||
|
||||
Returns `true` when the supplied window does not declare
|
||||
`isSecureContext: false`. Returns `true` when no window is supplied and
|
||||
no global `window` exists (SSR safe).
|
||||
|
||||
### `requireSecureContext(window?: ClipboardWindowLike): void`
|
||||
|
||||
Throws `ClipboardError(CLIPBOARD.INSECURE_CONTEXT)` when `isSecure`
|
||||
returns false. No-op otherwise.
|
||||
|
||||
### `waitForNextFocus(window?: ClipboardWindowLike, timeoutMs: number): Promise<boolean>`
|
||||
|
||||
Subscribes to the next `focus` event on the supplied window (or the
|
||||
global) and resolves `true` when the event fires. Resolves `false` if the
|
||||
window does not expose `addEventListener` or if the timeout elapses
|
||||
first. Internally guards against duplicate resolves with a `settled`
|
||||
flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module accepts a `ClipboardWindowLike` shape rather than the DOM `Window`
|
||||
type so tests + SSR consumers can inject minimal shims. The focus listener
|
||||
is registered with `{ once: true }` so it removes itself on first
|
||||
delivery; the timeout path additionally calls `removeEventListener`
|
||||
defensively in case the host environment ignores `{ once: true }`. The
|
||||
`settled` flag prevents the late-delivered focus event from resolving the
|
||||
promise after the timeout has already fired — a real-world race that the
|
||||
unit tests assert on.
|
||||
96
docs/foundation/data/clipboard/src/types/clipboard.types.md
Normal file
96
docs/foundation/data/clipboard/src/types/clipboard.types.md
Normal 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.
|
||||
Reference in New Issue
Block a user