Initial Commit
This commit is contained in:
92
packages/foundation/data/clipboard/README.md
Normal file
92
packages/foundation/data/clipboard/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# @suaveplan/clipboard - Enterprise Clipboard API abstraction: typed errors, OTel metrics, async queue serializer, secure-context guard, image/blob copy + read, paste-event read fallback, permission change subscription, focus-aware retry, sanitizer hook. Browser-tier, zero @suaveplan/* runtime deps beyond error + opentelemetry + types.
|
||||
|
||||
Enterprise Clipboard API abstraction for browser-tier consumers. Wraps the
|
||||
async Clipboard API with a textarea + `execCommand` fallback, a paste-event
|
||||
read fallback, image / blob copy + read via `ClipboardItem`, FIFO
|
||||
serialization, secure-context guard, permission change subscription,
|
||||
focus-aware retry, optional HTML sanitizer, structured errors via
|
||||
`@suaveplan/error`, and OpenTelemetry instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [errors](./src/errors/errors.md) — Stable error code registry, per-code metadata, and the concrete `ClipboardError` class for the `@suaveplan/clipboard` p…
|
||||
- [errors](./src/errors/errors.md) — Stable error code registry, per-code metadata, and the concrete `ClipboardError` class for the `@suaveplan/clipboard` p…
|
||||
- [fallback](./src/fallback/fallback.md) — Legacy clipboard fallback paths used when `navigator.clipboard` is unavailable or rejects. Provides a hidden-textarea +…
|
||||
- [fallback](./src/fallback/fallback.md) — Legacy clipboard fallback paths used when `navigator.clipboard` is unavailable or rejects. Provides a hidden-textarea +…
|
||||
- [manager](./src/manager/manager.md) — `ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`. It composes the navigator + window seams, the…
|
||||
- [helpers](./src/manager/helpers.md) — Pure helpers shared between `manager.ts` and `operations.ts`. Keeps both files under the line budget by housing the nav…
|
||||
- [manager](./src/manager/manager.md) — `ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`. It composes the navigator + window seams, the…
|
||||
- [operations](./src/manager/operations.md) — Pure operation functions invoked by `ClipboardManager`. Each function takes an `OperationContext` (navigator, window, o…
|
||||
- [observability](./src/observability/observability.md) — OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires four counters and one histogram on a single `Met…
|
||||
- [observability](./src/observability/observability.md) — OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires four counters and one histogram on a single `Met…
|
||||
- [permissions](./src/permissions/permissions.md) — Probe and subscribe to clipboard permission state via the browser's Permissions API. `checkPermissions` returns a snaps…
|
||||
- [permissions](./src/permissions/permissions.md) — Probe and subscribe to clipboard permission state via the browser's Permissions API. `checkPermissions` returns a snaps…
|
||||
- [queue](./src/queue/queue.md) — `AsyncQueue` is a minimal FIFO asynchronous queue that serializes operations on a single tail promise. The `ClipboardMa…
|
||||
- [queue](./src/queue/queue.md) — `AsyncQueue` is a minimal FIFO asynchronous queue that serializes operations on a single tail promise. The `ClipboardMa…
|
||||
- [sanitizer](./src/sanitizer/sanitizer.md) — Sanitizer hooks applied to inbound `text/html` clipboard payloads. The package ships an identity sanitizer (pass-throug…
|
||||
- [sanitizer](./src/sanitizer/sanitizer.md) — Sanitizer hooks applied to inbound `text/html` clipboard payloads. The package ships an identity sanitizer (pass-throug…
|
||||
- [security](./src/security/security.md) — Browser-side security helpers that guard clipboard operations against insecure contexts and orchestrate the focus-aware…
|
||||
- [security](./src/security/security.md) — Browser-side security helpers that guard clipboard operations against insecure contexts and orchestrate the focus-aware…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/clipboard
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { ClipboardManager } from "@suaveplan/clipboard";
|
||||
|
||||
const clipboard = new ClipboardManager({
|
||||
useFallback: true,
|
||||
requireSecureContext: false,
|
||||
serialize: true,
|
||||
});
|
||||
|
||||
const result = await clipboard.copyText("hello");
|
||||
if (!result.success) {
|
||||
console.error("copy failed:", result.code, result.error);
|
||||
}
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [`manager/manager.md`](./src/manager/manager.md) — `ClipboardManager`, the orchestrator.
|
||||
- [`manager/helpers.md`](./src/manager/helpers.md) — internal helpers used by the manager.
|
||||
- [`manager/operations.md`](./src/manager/operations.md) — pure operation functions.
|
||||
- [`errors/errors.md`](./src/errors/errors.md) — `CLIPBOARD_CODES`, `ClipboardError`, metadata.
|
||||
- [`fallback/fallback.md`](./src/fallback/fallback.md) — textarea + paste-event fallback paths.
|
||||
- [`observability/observability.md`](./src/observability/observability.md) — OpenTelemetry counters + histogram.
|
||||
- [`permissions/permissions.md`](./src/permissions/permissions.md) — permission probe + change subscription.
|
||||
- [`queue/queue.md`](./src/queue/queue.md) — `AsyncQueue` FIFO serializer.
|
||||
- [`sanitizer/sanitizer.md`](./src/sanitizer/sanitizer.md) — sanitizer interface + helpers.
|
||||
- [`security/security.md`](./src/security/security.md) — secure-context guards + focus utilities.
|
||||
|
||||
## Features
|
||||
|
||||
- Copy plain text, HTML, images, and arbitrary MIME blobs.
|
||||
- Read plain text and rich content with optional HTML sanitization.
|
||||
- Paste-event fallback when `navigator.clipboard.readText` is unavailable.
|
||||
- Permission probe + live subscription (`onPermissionChange`).
|
||||
- Async FIFO queue so concurrent calls execute in arrival order.
|
||||
- Secure-context guard with typed error code (`CLIPBOARD.INSECURE_CONTEXT`).
|
||||
- Focus-aware retry on `NotAllowedError: Document is not focused`.
|
||||
- Eight stable error codes under the `CLIPBOARD.*` namespace.
|
||||
- Five OpenTelemetry instruments wired into every public method.
|
||||
- SSR-safe — graceful degradation when `document` / `navigator` / `window` are undefined.
|
||||
|
||||
## Error codes
|
||||
|
||||
`CLIPBOARD.API_UNAVAILABLE`, `CLIPBOARD.PERMISSION_DENIED`,
|
||||
`CLIPBOARD.FALLBACK_FAILED`, `CLIPBOARD.INSECURE_CONTEXT`,
|
||||
`CLIPBOARD.NO_CONTENT`, `CLIPBOARD.READ_FAILED`,
|
||||
`CLIPBOARD.DOCUMENT_UNAVAILABLE`, `CLIPBOARD.QUEUE_DRAINED`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
110
packages/foundation/data/clipboard/package.json
Normal file
110
packages/foundation/data/clipboard/package.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "@suaveplan/clipboard",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"browser": "./dist/index.js",
|
||||
"description": "Enterprise Clipboard API abstraction: typed errors, OTel metrics, async queue serializer, secure-context guard, image/blob copy + read, paste-event read fallback, permission change subscription, focus-aware retry, sanitizer hook. Browser-tier, zero @suaveplan/* runtime deps beyond error + opentelemetry + types.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./manager/manager": {
|
||||
"import": {
|
||||
"types": "./dist/manager/manager.d.ts",
|
||||
"default": "./dist/manager/manager.js"
|
||||
}
|
||||
},
|
||||
"./fallback/fallback": {
|
||||
"import": {
|
||||
"types": "./dist/fallback/fallback.d.ts",
|
||||
"default": "./dist/fallback/fallback.js"
|
||||
}
|
||||
},
|
||||
"./permissions/permissions": {
|
||||
"import": {
|
||||
"types": "./dist/permissions/permissions.d.ts",
|
||||
"default": "./dist/permissions/permissions.js"
|
||||
}
|
||||
},
|
||||
"./queue/queue": {
|
||||
"import": {
|
||||
"types": "./dist/queue/queue.d.ts",
|
||||
"default": "./dist/queue/queue.js"
|
||||
}
|
||||
},
|
||||
"./observability/observability": {
|
||||
"import": {
|
||||
"types": "./dist/observability/observability.d.ts",
|
||||
"default": "./dist/observability/observability.js"
|
||||
}
|
||||
},
|
||||
"./errors/errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/errors.d.ts",
|
||||
"default": "./dist/errors/errors.js"
|
||||
}
|
||||
},
|
||||
"./security/security": {
|
||||
"import": {
|
||||
"types": "./dist/security/security.d.ts",
|
||||
"default": "./dist/security/security.js"
|
||||
}
|
||||
},
|
||||
"./sanitizer/sanitizer": {
|
||||
"import": {
|
||||
"types": "./dist/sanitizer/sanitizer.d.ts",
|
||||
"default": "./dist/sanitizer/sanitizer.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/clipboard.types.d.ts",
|
||||
"default": "./dist/types/clipboard.types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-dom": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
126
packages/foundation/data/clipboard/src/errors/errors.md
Normal file
126
packages/foundation/data/clipboard/src/errors/errors.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# `errors/errors.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error code registry, per-code metadata, and the concrete
|
||||
`ClipboardError` class for the `@suaveplan/clipboard` package. Every error
|
||||
path inside the manager, fallback, permissions, and security modules
|
||||
surfaces one of these codes so consumers can match programmatically
|
||||
without inspecting message strings. Codes are namespaced under
|
||||
`CLIPBOARD.*` and registered via the shared `defineCodes` helper from
|
||||
`@suaveplan/error` so the runtime values, the literal union type, and the
|
||||
metadata table stay in lock-step.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight stable codes: `API_UNAVAILABLE`, `PERMISSION_DENIED`,
|
||||
`FALLBACK_FAILED`, `INSECURE_CONTEXT`, `NO_CONTENT`, `READ_FAILED`,
|
||||
`DOCUMENT_UNAVAILABLE`, `QUEUE_DRAINED`.
|
||||
- Per-code metadata: `httpStatus`, `retryable`, `severity`, `description`.
|
||||
- `ClipboardError extends SuaveplanError<ClipboardErrorCode>` — preserves
|
||||
cause chains, supports OTel span events, and integrates with the
|
||||
project's typed-error contract.
|
||||
- `asClipboardError(code, cause, fallbackMessage)` lifts unknown thrown
|
||||
values into the typed hierarchy at every catch site.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLIPBOARD_CODES,
|
||||
ClipboardError,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
try {
|
||||
// ...
|
||||
} catch (cause) {
|
||||
throw new ClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
"Clipboard read rejected",
|
||||
{ cause: cause instanceof Error ? cause : new Error(String(cause)) },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
CLIPBOARD_ERROR_METADATA,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
function decide(err: unknown): { retry: boolean; status: number } {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
"code" in err &&
|
||||
typeof (err as { code?: string }).code === "string" &&
|
||||
(err as { code: string }).code.startsWith("CLIPBOARD.")
|
||||
) {
|
||||
const meta =
|
||||
CLIPBOARD_ERROR_METADATA[
|
||||
(err as { code: keyof typeof CLIPBOARD_ERROR_METADATA })
|
||||
.code
|
||||
];
|
||||
return { retry: meta.retryable, status: meta.httpStatus };
|
||||
}
|
||||
const lifted = asClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
err,
|
||||
"Unknown clipboard failure.",
|
||||
);
|
||||
return {
|
||||
retry: lifted.metadata.retryable,
|
||||
status: lifted.metadata.httpStatus,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CLIPBOARD_CODES`
|
||||
|
||||
`Readonly<Record<<key>, "CLIPBOARD.<key>">>` produced by `defineCodes`.
|
||||
The runtime values are namespaced strings; the type infers a literal
|
||||
union for exhaustive matching.
|
||||
|
||||
### `CLIPBOARD_ERROR_METADATA`
|
||||
|
||||
`Readonly<Record<ClipboardErrorCode, ClipboardCodeMetadata>>` — frozen map
|
||||
from each code to its `httpStatus`, `retryable`, `severity`, and
|
||||
`description` fields.
|
||||
|
||||
### `ClipboardCodeMetadata`
|
||||
|
||||
Interface with `httpStatus: number`, `severity: "info" | "warning" | "error"`,
|
||||
`retryable: boolean`, `description: string`.
|
||||
|
||||
### `ClipboardError`
|
||||
|
||||
Concrete subclass of `SuaveplanError<ClipboardErrorCode>`. Constructor
|
||||
signature: `new ClipboardError(code, message, options?)`. Exposes a
|
||||
`.metadata` getter that returns the row from
|
||||
`CLIPBOARD_ERROR_METADATA`.
|
||||
|
||||
### `asClipboardError(code, cause, fallbackMessage)`
|
||||
|
||||
Lifts an arbitrary `unknown` thrown value into a `ClipboardError`,
|
||||
preserving the cause chain. Uses `cause.message` when available and
|
||||
`fallbackMessage` otherwise. Always returns a `ClipboardError` with the
|
||||
supplied code.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package version of `defineCodes` is the canonical helper from
|
||||
`@suaveplan/error`; every package uses the same pattern so cross-
|
||||
cutting tooling (registry validators, retention checks, OpenSpec
|
||||
conformance) can introspect every namespace identically. Codes are
|
||||
intentionally namespaced strings rather than enums so they remain JSON-
|
||||
serialisable and stable across versions. The metadata table is `Object.freeze`-d
|
||||
so mutation is a typecheck error and a runtime error; reviewers can
|
||||
diff additions in pull requests without worrying about silent rewrites.
|
||||
101
packages/foundation/data/clipboard/src/errors/errors.test.ts
Normal file
101
packages/foundation/data/clipboard/src/errors/errors.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
CLIPBOARD_ERROR_METADATA,
|
||||
ClipboardError,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("CLIPBOARD_CODES", () => {
|
||||
it("CLIPBOARD_CODES has all eight stable codes", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(CLIPBOARD_CODES).sort()).toEqual(
|
||||
[
|
||||
"API_UNAVAILABLE",
|
||||
"DOCUMENT_UNAVAILABLE",
|
||||
"FALLBACK_FAILED",
|
||||
"INSECURE_CONTEXT",
|
||||
"NO_CONTENT",
|
||||
"PERMISSION_DENIED",
|
||||
"QUEUE_DRAINED",
|
||||
"READ_FAILED",
|
||||
].sort()
|
||||
);
|
||||
for (const value of Object.values(CLIPBOARD_CODES)) {
|
||||
expect(value.startsWith("CLIPBOARD.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("CLIPBOARD_ERROR_METADATA reports correct shape per code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
CLIPBOARD_ERROR_METADATA[CLIPBOARD_CODES.INSECURE_CONTEXT]
|
||||
).toEqual({
|
||||
httpStatus: 400,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
description: expect.any(String),
|
||||
});
|
||||
for (const code of Object.values(CLIPBOARD_CODES)) {
|
||||
const meta = CLIPBOARD_ERROR_METADATA[code];
|
||||
expect(typeof meta.httpStatus).toBe("number");
|
||||
expect(meta.description.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardError", () => {
|
||||
it("ClipboardError preserves code and cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("native");
|
||||
const err = new ClipboardError(
|
||||
CLIPBOARD_CODES.API_UNAVAILABLE,
|
||||
"no api",
|
||||
{ cause }
|
||||
);
|
||||
expect(err).toBeInstanceOf(ClipboardError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe(CLIPBOARD_CODES.API_UNAVAILABLE);
|
||||
expect(err.message).toBe("no api");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.name).toBe("ClipboardError");
|
||||
expect(err.metadata.httpStatus).toBe(501);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asClipboardError", () => {
|
||||
it("wraps Error cause and preserves its message", { timeout: 5000 }, () => {
|
||||
const cause = new Error("specific");
|
||||
const err = asClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
cause,
|
||||
"fallback"
|
||||
);
|
||||
expect(err.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
expect(err.message).toBe("specific");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("uses fallback message when cause has no message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = asClipboardError(
|
||||
CLIPBOARD_CODES.FALLBACK_FAILED,
|
||||
"string-thrown",
|
||||
"fallback-msg"
|
||||
);
|
||||
expect(err.message).toBe("fallback-msg");
|
||||
expect(err.cause).toBeInstanceOf(Error);
|
||||
expect((err.cause as Error).message).toBe("string-thrown");
|
||||
});
|
||||
|
||||
it("uses fallback message when Error message is empty", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = asClipboardError(
|
||||
CLIPBOARD_CODES.FALLBACK_FAILED,
|
||||
new Error(""),
|
||||
"fallback-msg"
|
||||
);
|
||||
expect(err.message).toBe("fallback-msg");
|
||||
});
|
||||
});
|
||||
133
packages/foundation/data/clipboard/src/errors/errors.ts
Normal file
133
packages/foundation/data/clipboard/src/errors/errors.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error code registry for `@suaveplan/clipboard`. Every error path
|
||||
* the package can take surfaces one of these codes so consumers (loggers,
|
||||
* retry policies, UI surfaces, telemetry pipelines) can classify failures
|
||||
* programmatically without parsing message strings.
|
||||
*/
|
||||
export const CLIPBOARD_CODES = defineCodes("CLIPBOARD", [
|
||||
"API_UNAVAILABLE",
|
||||
"PERMISSION_DENIED",
|
||||
"FALLBACK_FAILED",
|
||||
"INSECURE_CONTEXT",
|
||||
"NO_CONTENT",
|
||||
"READ_FAILED",
|
||||
"DOCUMENT_UNAVAILABLE",
|
||||
"QUEUE_DRAINED",
|
||||
] as const);
|
||||
|
||||
/** Union of every error code emitted by `@suaveplan/clipboard`. */
|
||||
export type ClipboardErrorCode =
|
||||
(typeof CLIPBOARD_CODES)[keyof typeof CLIPBOARD_CODES];
|
||||
|
||||
/** Per-code metadata for HTTP / retry / severity classification. */
|
||||
export interface ClipboardCodeMetadata {
|
||||
readonly httpStatus: number;
|
||||
readonly severity: "info" | "warning" | "error";
|
||||
readonly retryable: boolean;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** Mapping of every {@link ClipboardErrorCode} to its metadata. */
|
||||
export const CLIPBOARD_ERROR_METADATA: Readonly<
|
||||
Record<ClipboardErrorCode, ClipboardCodeMetadata>
|
||||
> = Object.freeze({
|
||||
[CLIPBOARD_CODES.API_UNAVAILABLE]: {
|
||||
httpStatus: 501,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
description:
|
||||
"navigator.clipboard is undefined; the host environment does not expose the async Clipboard API.",
|
||||
},
|
||||
[CLIPBOARD_CODES.PERMISSION_DENIED]: {
|
||||
httpStatus: 403,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
description:
|
||||
"The Permissions API reported state=denied for clipboard-read or clipboard-write.",
|
||||
},
|
||||
[CLIPBOARD_CODES.FALLBACK_FAILED]: {
|
||||
httpStatus: 500,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
description:
|
||||
"document.execCommand('copy') returned false or threw during the textarea fallback.",
|
||||
},
|
||||
[CLIPBOARD_CODES.INSECURE_CONTEXT]: {
|
||||
httpStatus: 400,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
description:
|
||||
"window.isSecureContext is false; clipboard operations require HTTPS or localhost when requireSecureContext is enabled.",
|
||||
},
|
||||
[CLIPBOARD_CODES.NO_CONTENT]: {
|
||||
httpStatus: 400,
|
||||
severity: "warning",
|
||||
retryable: false,
|
||||
description:
|
||||
"copyRichContent was called with empty ClipboardData (no text, html, image, or blobs).",
|
||||
},
|
||||
[CLIPBOARD_CODES.READ_FAILED]: {
|
||||
httpStatus: 500,
|
||||
severity: "error",
|
||||
retryable: true,
|
||||
description:
|
||||
"Clipboard read failed; the underlying API rejected or the paste-event fallback timed out.",
|
||||
},
|
||||
[CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE]: {
|
||||
httpStatus: 501,
|
||||
severity: "error",
|
||||
retryable: false,
|
||||
description:
|
||||
"document is undefined; the fallback path cannot run outside a DOM environment.",
|
||||
},
|
||||
[CLIPBOARD_CODES.QUEUE_DRAINED]: {
|
||||
httpStatus: 503,
|
||||
severity: "warning",
|
||||
retryable: true,
|
||||
description:
|
||||
"ClipboardManager was disposed while a queued operation was still pending.",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Base error class for every failure raised by `@suaveplan/clipboard`.
|
||||
* Extends {@link SuaveplanError} so consumers receive cause-chain traversal,
|
||||
* Zod-validated wire format, redaction hooks, and the OTel span-event
|
||||
* adapter for free.
|
||||
*/
|
||||
export class ClipboardError extends SuaveplanError<ClipboardErrorCode> {
|
||||
constructor(
|
||||
code: ClipboardErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "ClipboardError";
|
||||
}
|
||||
|
||||
/** Metadata for this error's code. */
|
||||
get metadata(): ClipboardCodeMetadata {
|
||||
return CLIPBOARD_ERROR_METADATA[this.code];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift an unknown thrown value into a {@link ClipboardError}. Used at every
|
||||
* `catch` site so call sites can rely on the typed error contract.
|
||||
*/
|
||||
export function asClipboardError(
|
||||
code: ClipboardErrorCode,
|
||||
cause: unknown,
|
||||
fallbackMessage: string
|
||||
): ClipboardError {
|
||||
const message =
|
||||
cause instanceof Error && cause.message
|
||||
? cause.message
|
||||
: fallbackMessage;
|
||||
const causeError =
|
||||
cause instanceof Error ? cause : new Error(String(cause));
|
||||
return new ClipboardError(code, message, { cause: causeError });
|
||||
}
|
||||
8
packages/foundation/data/clipboard/src/errors/index.ts
Normal file
8
packages/foundation/data/clipboard/src/errors/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
CLIPBOARD_ERROR_METADATA,
|
||||
type ClipboardCodeMetadata,
|
||||
ClipboardError,
|
||||
type ClipboardErrorCode,
|
||||
} from "./errors.js";
|
||||
94
packages/foundation/data/clipboard/src/fallback/fallback.md
Normal file
94
packages/foundation/data/clipboard/src/fallback/fallback.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `fallback/fallback.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Legacy clipboard fallback paths used when `navigator.clipboard` is
|
||||
unavailable or rejects. Provides a hidden-textarea + `execCommand("copy")`
|
||||
write path and a paste-event-based read path. Both return the same
|
||||
`ClipboardResult` shape as the API path so the manager's call sites do
|
||||
not branch on which mechanism actually ran. The paste-event fallback is
|
||||
purpose-built for environments — older Safari, embedded WebViews, and
|
||||
permission-denied contexts — where `navigator.clipboard.readText`
|
||||
rejects but the user can paste manually.
|
||||
|
||||
## Features
|
||||
|
||||
- `copyTextFallback(text)` — synchronous textarea + `execCommand` copy.
|
||||
- `readTextFallback(timeoutMs)` — focus + paste-event capture with a
|
||||
configurable timeout.
|
||||
- `dispatchSyntheticPaste(text)` — test helper that fires a synthetic
|
||||
`paste` event on the most recent hidden host.
|
||||
- SSR-safe — both copy and read paths return
|
||||
`CLIPBOARD.DOCUMENT_UNAVAILABLE` when `document` is undefined.
|
||||
- Off-screen, non-interactive host element so users never see the
|
||||
textarea visibly steal focus.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { copyTextFallback } from "@suaveplan/clipboard";
|
||||
|
||||
const result = copyTextFallback("hello");
|
||||
if (!result.success) {
|
||||
console.warn("legacy copy failed:", result.code);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
readTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
async function readClipboardWithPasteFallback(): Promise<string | undefined> {
|
||||
const result = await readTextFallback(2000);
|
||||
return result.success ? result.data : undefined;
|
||||
}
|
||||
|
||||
// Inside a test harness:
|
||||
const promise = readTextFallback(500);
|
||||
queueMicrotask(() => dispatchSyntheticPaste("from-test"));
|
||||
const result = await promise;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `copyTextFallback(text: string): ClipboardResult<string>`
|
||||
|
||||
Stages a hidden `<textarea>` carrying `text`, selects it, runs
|
||||
`document.execCommand("copy")`, and removes the textarea on cleanup.
|
||||
Returns `{ success: true, data, method: "fallback" }` on success and a
|
||||
structured failure with `code: CLIPBOARD.FALLBACK_FAILED` (or
|
||||
`CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser) otherwise.
|
||||
|
||||
### `readTextFallback(timeoutMs: number): Promise<ClipboardResult<string>>`
|
||||
|
||||
Creates a hidden textarea, focuses it, listens for the first `paste`
|
||||
event, and resolves with the `text/plain` payload carried by the event.
|
||||
Resolves with `code: CLIPBOARD.READ_FAILED` if the timeout elapses
|
||||
first and `code: CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser.
|
||||
|
||||
### `dispatchSyntheticPaste(text: string): boolean`
|
||||
|
||||
Test helper. Locates the most recent hidden host (`textarea[aria-hidden="true"]`)
|
||||
and dispatches a `paste` ClipboardEvent carrying `text` as `text/plain`.
|
||||
Returns `true` when a host was found, `false` otherwise.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hidden textarea is positioned at `-9999px` and given `opacity: 0`
|
||||
plus `aria-hidden="true"` so assistive technologies skip it. The cleanup
|
||||
runs in a `finally` block — even if `execCommand` throws, the textarea is
|
||||
removed. The read fallback tracks a `settled` flag so a duplicate or
|
||||
post-timeout `paste` event cannot resolve the promise twice. The paste
|
||||
event may carry a null `clipboardData` on some browsers; the module
|
||||
falls back to an empty string in that case rather than rejecting, since
|
||||
"clipboard contained nothing" is a valid outcome. The synthetic-paste
|
||||
helper is exported to keep tests deterministic — production code never
|
||||
calls it.
|
||||
208
packages/foundation/data/clipboard/src/fallback/fallback.test.ts
Normal file
208
packages/foundation/data/clipboard/src/fallback/fallback.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLIPBOARD_CODES } from "../errors/errors.js";
|
||||
import {
|
||||
copyTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
readTextFallback,
|
||||
} from "./fallback.js";
|
||||
|
||||
function withExecCommand<T>(
|
||||
impl: (() => boolean) | (() => never),
|
||||
fn: () => T
|
||||
): T {
|
||||
const original = document.execCommand;
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
impl as () => boolean;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
document.execCommand = original;
|
||||
}
|
||||
}
|
||||
|
||||
describe("copyTextFallback", () => {
|
||||
it("copies text when execCommand returns true", { timeout: 5000 }, () => {
|
||||
const result = withExecCommand(
|
||||
() => true,
|
||||
() => copyTextFallback("hello")
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: "hello",
|
||||
method: "fallback",
|
||||
});
|
||||
});
|
||||
|
||||
it("copyTextFallback reports FALLBACK_FAILED when execCommand returns false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = withExecCommand(
|
||||
() => false,
|
||||
() => copyTextFallback("x")
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
|
||||
expect(result.method).toBe("fallback");
|
||||
});
|
||||
|
||||
it("copyTextFallback reports FALLBACK_FAILED when execCommand throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = withExecCommand(
|
||||
() => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
() => copyTextFallback("x")
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
|
||||
expect(result.error).toBe("denied");
|
||||
});
|
||||
|
||||
it("copyTextFallback reports DOCUMENT_UNAVAILABLE when document missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const originalDoc = globalThis.document;
|
||||
(globalThis as unknown as { document?: Document }).document = undefined;
|
||||
try {
|
||||
const result = copyTextFallback("x");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe(CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE);
|
||||
} finally {
|
||||
(globalThis as unknown as { document?: Document }).document =
|
||||
originalDoc;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTextFallback", () => {
|
||||
it("readTextFallback returns pasted value when paste event fires", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const promise = readTextFallback(500);
|
||||
// Wait one tick for the host to be staged.
|
||||
setTimeout(() => {
|
||||
const ok = dispatchSyntheticPaste("pasted-text");
|
||||
expect(ok).toBe(true);
|
||||
}, 10);
|
||||
const result = await promise;
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: "pasted-text",
|
||||
method: "fallback",
|
||||
});
|
||||
});
|
||||
|
||||
it("readTextFallback returns empty string when paste lacks text/plain", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const promise = readTextFallback(500);
|
||||
setTimeout(() => {
|
||||
const candidates = document.querySelectorAll(
|
||||
'textarea[aria-hidden="true"]'
|
||||
);
|
||||
const host = candidates.item(candidates.length - 1);
|
||||
const ev = new ClipboardEvent("paste", {
|
||||
clipboardData: null as unknown as DataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
host?.dispatchEvent(ev);
|
||||
}, 5);
|
||||
const result = await promise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toBe("");
|
||||
});
|
||||
|
||||
it("readTextFallback returns READ_FAILED on timeout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await readTextFallback(20);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
expect(result.method).toBe("fallback");
|
||||
});
|
||||
|
||||
it("readTextFallback reports DOCUMENT_UNAVAILABLE when document missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const originalDoc = globalThis.document;
|
||||
(globalThis as unknown as { document?: Document }).document = undefined;
|
||||
try {
|
||||
const result = await readTextFallback(100);
|
||||
expect(result.code).toBe(CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE);
|
||||
} finally {
|
||||
(globalThis as unknown as { document?: Document }).document =
|
||||
originalDoc;
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores duplicate paste events after settling", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const promise = readTextFallback(500);
|
||||
// Capture host reference inside setTimeout so it has been staged.
|
||||
let host: Element | null = null;
|
||||
const makeEvent = (text: string): ClipboardEvent => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData("text/plain", text);
|
||||
return new ClipboardEvent("paste", {
|
||||
clipboardData: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
};
|
||||
setTimeout(() => {
|
||||
const candidates = document.querySelectorAll(
|
||||
'textarea[aria-hidden="true"]'
|
||||
);
|
||||
host = candidates.item(candidates.length - 1);
|
||||
// First event resolves the promise.
|
||||
host?.dispatchEvent(makeEvent("first"));
|
||||
// Second event hits the settled-guard branch — host has been
|
||||
// detached from DOM by cleanup, but the listener remains attached
|
||||
// to the orphan node and re-fires onPaste, which short-circuits.
|
||||
host?.dispatchEvent(makeEvent("second"));
|
||||
}, 10);
|
||||
const result = await promise;
|
||||
expect(result.data).toBe("first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTextFallback timeout idempotence", () => {
|
||||
it("timeout firing after paste does not double-resolve", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Short timeout so it fires synchronously after paste.
|
||||
const promise = readTextFallback(30);
|
||||
setTimeout(() => dispatchSyntheticPaste("real"), 5);
|
||||
const first = await promise;
|
||||
expect(first.data).toBe("real");
|
||||
// Allow the original 30ms timeout to elapse — it must noop.
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
expect(first.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchSyntheticPaste", () => {
|
||||
it("returns false when no host exists", { timeout: 5000 }, () => {
|
||||
// Remove any leftover hosts.
|
||||
for (const host of document.querySelectorAll(
|
||||
'textarea[aria-hidden="true"]'
|
||||
)) {
|
||||
host.parentNode?.removeChild(host);
|
||||
}
|
||||
expect(dispatchSyntheticPaste("x")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when document missing", { timeout: 5000 }, () => {
|
||||
const originalDoc = globalThis.document;
|
||||
(globalThis as unknown as { document?: Document }).document = undefined;
|
||||
try {
|
||||
expect(dispatchSyntheticPaste("x")).toBe(false);
|
||||
} finally {
|
||||
(globalThis as unknown as { document?: Document }).document =
|
||||
originalDoc;
|
||||
}
|
||||
});
|
||||
});
|
||||
175
packages/foundation/data/clipboard/src/fallback/fallback.ts
Normal file
175
packages/foundation/data/clipboard/src/fallback/fallback.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
ClipboardError,
|
||||
} from "../errors/errors.js";
|
||||
import type { ClipboardResult } from "../types/clipboard.types.js";
|
||||
|
||||
/**
|
||||
* Stage a hidden textarea for the `execCommand` fallback. Off-screen and
|
||||
* non-interactive so it can never steal focus visibly. Caller is
|
||||
* responsible for removing it via the returned cleanup.
|
||||
*/
|
||||
function stageTextarea(value: string): {
|
||||
textarea: HTMLTextAreaElement;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.setAttribute("aria-hidden", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
return {
|
||||
textarea,
|
||||
cleanup: (): void => {
|
||||
textarea.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `text` via the legacy `<textarea>` + `document.execCommand("copy")`
|
||||
* path. Returns a structured {@link ClipboardResult} with method
|
||||
* `"fallback"` and (on failure) a `CLIPBOARD.*` code.
|
||||
*/
|
||||
export function copyTextFallback(text: string): ClipboardResult<string> {
|
||||
if (typeof document === "undefined") {
|
||||
const error = new ClipboardError(
|
||||
CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE,
|
||||
"document is undefined; cannot run clipboard fallback."
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
method: "fallback",
|
||||
};
|
||||
}
|
||||
const { cleanup } = stageTextarea(text);
|
||||
try {
|
||||
const ok = document.execCommand("copy");
|
||||
if (ok) {
|
||||
return { success: true, data: text, method: "fallback" };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: "document.execCommand('copy') returned false.",
|
||||
code: CLIPBOARD_CODES.FALLBACK_FAILED,
|
||||
method: "fallback",
|
||||
};
|
||||
} catch (cause) {
|
||||
const error = asClipboardError(
|
||||
CLIPBOARD_CODES.FALLBACK_FAILED,
|
||||
cause,
|
||||
"document.execCommand('copy') threw."
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
method: "fallback",
|
||||
};
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read plain text from the clipboard via a paste-event fallback. Creates a
|
||||
* hidden contenteditable element, focuses it, and resolves with the value
|
||||
* carried by the first `paste` event. Times out with
|
||||
* `CLIPBOARD.READ_FAILED` after `timeoutMs` milliseconds.
|
||||
*/
|
||||
export function readTextFallback(
|
||||
timeoutMs: number
|
||||
): Promise<ClipboardResult<string>> {
|
||||
if (typeof document === "undefined") {
|
||||
const error = new ClipboardError(
|
||||
CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE,
|
||||
"document is undefined; cannot run paste-event fallback."
|
||||
);
|
||||
return Promise.resolve({
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
method: "fallback",
|
||||
});
|
||||
}
|
||||
return new Promise<ClipboardResult<string>>((resolve) => {
|
||||
const host = document.createElement("textarea");
|
||||
host.setAttribute("aria-hidden", "true");
|
||||
host.style.position = "fixed";
|
||||
host.style.left = "-9999px";
|
||||
host.style.top = "-9999px";
|
||||
host.style.opacity = "0";
|
||||
document.body.appendChild(host);
|
||||
let settled = false;
|
||||
const cleanup = (): void => {
|
||||
// Keep the listener attached after first paste; the `settled`
|
||||
// guard below prevents double-resolve when a host that's already
|
||||
// been detached fires a duplicate paste event.
|
||||
host.remove();
|
||||
};
|
||||
const onPaste = (ev: Event): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
const data =
|
||||
(ev as ClipboardEvent).clipboardData?.getData("text/plain") ??
|
||||
"";
|
||||
cleanup();
|
||||
resolve({ success: true, data, method: "fallback" });
|
||||
};
|
||||
host.addEventListener("paste", onPaste);
|
||||
host.focus();
|
||||
setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
const error = new ClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
"Paste-event fallback timed out before any paste event fired."
|
||||
);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
method: "fallback",
|
||||
});
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic `paste` event on the most recent hidden host (the
|
||||
* one staged by `readTextFallback`). Intended for tests: the host is the
|
||||
* last child of `document.body` whose `aria-hidden` attribute is `"true"`.
|
||||
*/
|
||||
export function dispatchSyntheticPaste(text: string): boolean {
|
||||
if (typeof document === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const candidates = document.querySelectorAll(
|
||||
'textarea[aria-hidden="true"]'
|
||||
);
|
||||
const host = candidates.item(candidates.length - 1);
|
||||
if (host === null) {
|
||||
return false;
|
||||
}
|
||||
const dt = new DataTransfer();
|
||||
dt.setData("text/plain", text);
|
||||
const ev = new ClipboardEvent("paste", {
|
||||
clipboardData: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
host.dispatchEvent(ev);
|
||||
return true;
|
||||
}
|
||||
5
packages/foundation/data/clipboard/src/fallback/index.ts
Normal file
5
packages/foundation/data/clipboard/src/fallback/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
copyTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
readTextFallback,
|
||||
} from "./fallback.js";
|
||||
96
packages/foundation/data/clipboard/src/index.test.ts
Normal file
96
packages/foundation/data/clipboard/src/index.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as pkg from "./index.js";
|
||||
|
||||
const PKG_ROOT = resolve(__dirname, "..");
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const s = statSync(full);
|
||||
if (s.isDirectory()) {
|
||||
yield* walk(full);
|
||||
} else {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("@suaveplan/clipboard package surface", () => {
|
||||
it("exposes the documented public symbols", { timeout: 5000 }, () => {
|
||||
const exported = Object.keys(pkg).sort();
|
||||
const required = [
|
||||
"AsyncQueue",
|
||||
"CLIPBOARD_CODES",
|
||||
"CLIPBOARD_ERROR_METADATA",
|
||||
"ClipboardError",
|
||||
"ClipboardManager",
|
||||
"DEFAULT_CLIPBOARD_OPTIONS",
|
||||
"asClipboardError",
|
||||
"checkPermissions",
|
||||
"copyTextFallback",
|
||||
"createClipboardObservability",
|
||||
"createTagAllowlistSanitizer",
|
||||
"dispatchSyntheticPaste",
|
||||
"identitySanitizer",
|
||||
"isSecure",
|
||||
"onPermissionChange",
|
||||
"readTextFallback",
|
||||
"requireSecureContext",
|
||||
"waitForNextFocus",
|
||||
];
|
||||
for (const sym of required) {
|
||||
expect(exported).toContain(sym);
|
||||
}
|
||||
});
|
||||
|
||||
it("package.json exposes one subpath per feature module", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subpaths = Object.keys(manifest.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json"
|
||||
);
|
||||
// Expect a subpath for each feature plus the root barrel.
|
||||
expect(subpaths.length).toBeGreaterThanOrEqual(9);
|
||||
for (const sub of subpaths) {
|
||||
const entry = manifest.exports[sub] as Record<string, unknown>;
|
||||
expect(entry).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("package has no node imports", { timeout: 5000 }, () => {
|
||||
const src = resolve(__dirname);
|
||||
for (const file of walk(src)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (file.endsWith(".test.ts")) continue;
|
||||
const text = readFileSync(file, "utf8");
|
||||
expect(text.includes('from "node:')).toBe(false);
|
||||
expect(text.includes("require('node:")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("every implementation file has a sibling .md", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const src = resolve(__dirname);
|
||||
const missing: string[] = [];
|
||||
for (const file of walk(src)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (
|
||||
file.endsWith(".test.ts") ||
|
||||
file.endsWith(".types.ts") ||
|
||||
file.endsWith("/index.ts")
|
||||
)
|
||||
continue;
|
||||
const md = file.replace(/\.ts$/, ".md");
|
||||
if (!existsSync(md)) {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
69
packages/foundation/data/clipboard/src/index.ts
Normal file
69
packages/foundation/data/clipboard/src/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* `@suaveplan/clipboard` — enterprise Clipboard API abstraction.
|
||||
*
|
||||
* Browser-tier facade over `navigator.clipboard` with a textarea +
|
||||
* `execCommand` fallback, paste-event read fallback, async FIFO
|
||||
* serialization, secure-context guard, image / blob copy + read via
|
||||
* `ClipboardItem`, permission change subscription, focus-aware retry,
|
||||
* sanitizer hook, OpenTelemetry instrumentation, and a typed error
|
||||
* hierarchy under the `CLIPBOARD.*` namespace.
|
||||
*/
|
||||
|
||||
// Errors
|
||||
export {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
CLIPBOARD_ERROR_METADATA,
|
||||
type ClipboardCodeMetadata,
|
||||
ClipboardError,
|
||||
type ClipboardErrorCode,
|
||||
} from "./errors/errors.js";
|
||||
// Fallback
|
||||
export {
|
||||
copyTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
readTextFallback,
|
||||
} from "./fallback/fallback.js";
|
||||
// Manager
|
||||
export { ClipboardManager } from "./manager/manager.js";
|
||||
// Observability
|
||||
export { createClipboardObservability } from "./observability/observability.js";
|
||||
export type {
|
||||
ClipboardCounters,
|
||||
ClipboardHistograms,
|
||||
ClipboardObservability,
|
||||
ClipboardObservabilityOptions,
|
||||
} from "./observability/observability.types.js";
|
||||
// Permissions
|
||||
export {
|
||||
checkPermissions,
|
||||
onPermissionChange,
|
||||
} from "./permissions/permissions.js";
|
||||
// Queue
|
||||
export { AsyncQueue } from "./queue/queue.js";
|
||||
// Sanitizer
|
||||
export {
|
||||
createTagAllowlistSanitizer,
|
||||
identitySanitizer,
|
||||
} from "./sanitizer/sanitizer.js";
|
||||
// Security
|
||||
export {
|
||||
isSecure,
|
||||
requireSecureContext,
|
||||
waitForNextFocus,
|
||||
} from "./security/security.js";
|
||||
// Types
|
||||
export {
|
||||
type ClipboardBlobMap,
|
||||
type ClipboardData,
|
||||
type ClipboardNavigator,
|
||||
type ClipboardOptions,
|
||||
type ClipboardPermissionState,
|
||||
type ClipboardResult,
|
||||
type ClipboardSanitizer,
|
||||
type ClipboardWindowLike,
|
||||
DEFAULT_CLIPBOARD_OPTIONS,
|
||||
type PermissionChangeListener,
|
||||
type PermissionResult,
|
||||
type PermissionStatusLike,
|
||||
} from "./types/clipboard.types.js";
|
||||
96
packages/foundation/data/clipboard/src/manager/helpers.md
Normal file
96
packages/foundation/data/clipboard/src/manager/helpers.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `manager/helpers.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure helpers shared between `manager.ts` and `operations.ts`. Keeps both
|
||||
files under the line budget by housing the navigator / window resolution,
|
||||
the structured failure builder, the focus-error detector, and the
|
||||
`ClipboardItem` build + read pipelines in a single, side-effect-free
|
||||
module that tests can exercise directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveNavigator(nav?)` — caller, global, or empty shim.
|
||||
- `resolveWindow(win?)` — caller, global, or `undefined`.
|
||||
- `failure(code, message, method)` — typed `ClipboardResult` failure.
|
||||
- `isFocusError(cause)` — detects the canonical focus-required rejection.
|
||||
- `buildClipboardItems(data)` — MIME → `Blob` map for `ClipboardItem`.
|
||||
- `readClipboardItems(items, sanitizer?)` — pulls text / html / image /
|
||||
blobs out of a list of `ClipboardItem` instances.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { failure, CLIPBOARD_CODES } from "@suaveplan/clipboard";
|
||||
|
||||
const r = failure(CLIPBOARD_CODES.NO_CONTENT, "empty", "api");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildClipboardItems,
|
||||
readClipboardItems,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const items = buildClipboardItems({
|
||||
text: "hello",
|
||||
html: "<b>hi</b>",
|
||||
image: pngBlob,
|
||||
blobs: { "application/json": jsonBlob },
|
||||
});
|
||||
|
||||
const clipboardItem = new ClipboardItem(items);
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
|
||||
// Round-trip back:
|
||||
const read = await navigator.clipboard.read();
|
||||
const data = await readClipboardItems(read, (html) => html.trim());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `resolveNavigator(nav?: ClipboardNavigator): ClipboardNavigator`
|
||||
|
||||
Returns `nav` when defined, otherwise the global `navigator`, otherwise
|
||||
the empty object `{}`.
|
||||
|
||||
### `resolveWindow(win?: ClipboardWindowLike): ClipboardWindowLike | undefined`
|
||||
|
||||
Returns `win` when defined, otherwise the global `window`, otherwise
|
||||
`undefined`.
|
||||
|
||||
### `failure<T>(code, message, method): ClipboardResult<T>`
|
||||
|
||||
Returns `{ success: false, error: message, code, method }`. Generic over
|
||||
`T` so call sites compose with their own result types.
|
||||
|
||||
### `isFocusError(cause: unknown): boolean`
|
||||
|
||||
Returns `true` when `cause` is an `Error` whose `name === "NotAllowedError"`
|
||||
and whose `message` (case-insensitively) contains `"focus"`. The browser
|
||||
signals "Clipboard needs page focus" with this exact shape.
|
||||
|
||||
### `buildClipboardItems(data: ClipboardData): Record<string, Blob>`
|
||||
|
||||
Builds a MIME → Blob map for `new ClipboardItem(...)`. Skips empty
|
||||
`text` and `html`. Defaults a typeless image blob to `image/png`.
|
||||
|
||||
### `readClipboardItems(items, sanitizer?): Promise<ClipboardData>`
|
||||
|
||||
Iterates every entry of every `ClipboardItem`, populating `text`,
|
||||
`html`, `image`, and `blobs`. The first image-typed entry wins. The
|
||||
`sanitizer` (when supplied) is applied to the raw `text/html` payload
|
||||
before returning.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helpers are intentionally side-effect-free and free of telemetry
|
||||
calls so the same code runs from `runCopyText`, `runCopyRich`, etc.,
|
||||
without double-recording counters. `buildClipboardItems` and
|
||||
`readClipboardItems` are mirror images of each other — every MIME slot
|
||||
that `buildClipboardItems` writes is read back by `readClipboardItems`
|
||||
into the same `ClipboardData` shape. The focus-error detector matches
|
||||
both Chromium ("Document is not focused.") and WebKit ("Document not
|
||||
focused.") wording.
|
||||
177
packages/foundation/data/clipboard/src/manager/helpers.test.ts
Normal file
177
packages/foundation/data/clipboard/src/manager/helpers.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLIPBOARD_CODES } from "../errors/errors.js";
|
||||
import {
|
||||
buildClipboardItems,
|
||||
failure,
|
||||
isFocusError,
|
||||
readClipboardItems,
|
||||
resolveNavigator,
|
||||
resolveWindow,
|
||||
} from "./helpers.js";
|
||||
|
||||
class FakeClipboardItem {
|
||||
public readonly types: string[];
|
||||
constructor(public readonly items: Record<string, Blob>) {
|
||||
this.types = Object.keys(items);
|
||||
}
|
||||
async getType(type: string): Promise<Blob> {
|
||||
return this.items[type] as Blob;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveNavigator", () => {
|
||||
it("returns supplied navigator", { timeout: 5000 }, () => {
|
||||
const nav = { clipboard: undefined };
|
||||
expect(resolveNavigator(nav)).toBe(nav);
|
||||
});
|
||||
|
||||
it("falls back to global navigator", { timeout: 5000 }, () => {
|
||||
expect(resolveNavigator()).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty object when global navigator missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (globalThis as unknown as { navigator?: Navigator })
|
||||
.navigator;
|
||||
(globalThis as unknown as { navigator?: Navigator }).navigator =
|
||||
undefined;
|
||||
try {
|
||||
expect(resolveNavigator()).toEqual({});
|
||||
} finally {
|
||||
(globalThis as unknown as { navigator?: Navigator }).navigator =
|
||||
original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWindow", () => {
|
||||
it("returns supplied window", { timeout: 5000 }, () => {
|
||||
const win = { isSecureContext: true };
|
||||
expect(resolveWindow(win)).toBe(win);
|
||||
});
|
||||
|
||||
it("falls back to global window", { timeout: 5000 }, () => {
|
||||
expect(resolveWindow()).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined when global window missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = (globalThis as unknown as { window?: Window }).window;
|
||||
(globalThis as unknown as { window?: Window }).window = undefined;
|
||||
try {
|
||||
expect(resolveWindow()).toBeUndefined();
|
||||
} finally {
|
||||
(globalThis as unknown as { window?: Window }).window = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("failure", () => {
|
||||
it("builds a structured failure", { timeout: 5000 }, () => {
|
||||
const r = failure(CLIPBOARD_CODES.NO_CONTENT, "empty", "api");
|
||||
expect(r).toEqual({
|
||||
success: false,
|
||||
error: "empty",
|
||||
code: CLIPBOARD_CODES.NO_CONTENT,
|
||||
method: "api",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFocusError", () => {
|
||||
it("recognises NotAllowedError with 'focus' in the message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new Error("Document is not focused.");
|
||||
err.name = "NotAllowedError";
|
||||
expect(isFocusError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other names", { timeout: 5000 }, () => {
|
||||
const err = new Error("Document is not focused.");
|
||||
expect(isFocusError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects NotAllowedError without focus phrase", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new Error("blocked");
|
||||
err.name = "NotAllowedError";
|
||||
expect(isFocusError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-Error throwables", { timeout: 5000 }, () => {
|
||||
expect(isFocusError("focus")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClipboardItems", () => {
|
||||
it("includes provided text, html, image, and blobs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const png = new Blob([new Uint8Array([1])], { type: "image/png" });
|
||||
const json = new Blob(['{"a":1}'], { type: "application/json" });
|
||||
const items = buildClipboardItems({
|
||||
text: "t",
|
||||
html: "<b>x</b>",
|
||||
image: png,
|
||||
blobs: { "application/json": json },
|
||||
});
|
||||
expect(Object.keys(items).sort()).toEqual(
|
||||
["application/json", "image/png", "text/html", "text/plain"].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("skips empty text and empty html", { timeout: 5000 }, () => {
|
||||
const items = buildClipboardItems({ text: "", html: "" });
|
||||
expect(items).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults missing image MIME to image/png", { timeout: 5000 }, () => {
|
||||
const blob = new Blob([new Uint8Array([1])], { type: "" });
|
||||
const items = buildClipboardItems({ image: blob });
|
||||
expect("image/png" in items).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readClipboardItems", () => {
|
||||
it("reads text/html/image/arbitrary blob from a single item", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const png = new Blob([new Uint8Array([1])], { type: "image/png" });
|
||||
const json = new Blob(['{"a":1}'], { type: "application/json" });
|
||||
const item = new FakeClipboardItem({
|
||||
"text/plain": new Blob(["t"], { type: "text/plain" }),
|
||||
"text/html": new Blob(["<b>x</b>"], { type: "text/html" }),
|
||||
"image/png": png,
|
||||
"application/json": json,
|
||||
}) as unknown as ClipboardItem;
|
||||
const data = await readClipboardItems([item], undefined);
|
||||
expect(data.text).toBe("t");
|
||||
expect(data.html).toBe("<b>x</b>");
|
||||
expect(data.image).toBeDefined();
|
||||
expect(data.blobs?.["application/json"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("applies sanitizer when provided", { timeout: 5000 }, async () => {
|
||||
const item = new FakeClipboardItem({
|
||||
"text/html": new Blob(["<p>ok</p><script>bad</script>"], {
|
||||
type: "text/html",
|
||||
}),
|
||||
}) as unknown as ClipboardItem;
|
||||
const data = await readClipboardItems([item], (html) =>
|
||||
html.replace(/<script[\s\S]*?<\/script>/g, "")
|
||||
);
|
||||
expect(data.html).toBe("<p>ok</p>");
|
||||
});
|
||||
|
||||
it("returns empty object when no recognised types present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const item = new FakeClipboardItem({}) as unknown as ClipboardItem;
|
||||
const data = await readClipboardItems([item], undefined);
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
});
|
||||
119
packages/foundation/data/clipboard/src/manager/helpers.ts
Normal file
119
packages/foundation/data/clipboard/src/manager/helpers.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { ClipboardErrorCode } from "../errors/errors.js";
|
||||
import type {
|
||||
ClipboardData,
|
||||
ClipboardNavigator,
|
||||
ClipboardResult,
|
||||
ClipboardWindowLike,
|
||||
} from "../types/clipboard.types.js";
|
||||
|
||||
/** Resolve the effective navigator object — caller-supplied, global, or empty shim. */
|
||||
export function resolveNavigator(nav?: ClipboardNavigator): ClipboardNavigator {
|
||||
if (nav !== undefined) {
|
||||
return nav;
|
||||
}
|
||||
if (typeof navigator !== "undefined") {
|
||||
return navigator as unknown as ClipboardNavigator;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Resolve the effective window object — caller-supplied, global, or undefined. */
|
||||
export function resolveWindow(
|
||||
win?: ClipboardWindowLike
|
||||
): ClipboardWindowLike | undefined {
|
||||
if (win !== undefined) {
|
||||
return win;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
return window as unknown as ClipboardWindowLike;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Build a failure {@link ClipboardResult} carrying the given code + message + method. */
|
||||
export function failure<T>(
|
||||
code: ClipboardErrorCode,
|
||||
message: string,
|
||||
method: "api" | "fallback"
|
||||
): ClipboardResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
code,
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the supplied cause is a `NotAllowedError` with a message
|
||||
* mentioning focus — the canonical browser signal that the page must
|
||||
* regain focus before the Clipboard API will run.
|
||||
*/
|
||||
export function isFocusError(cause: unknown): boolean {
|
||||
if (!(cause instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
cause.name === "NotAllowedError" &&
|
||||
cause.message.toLowerCase().includes("focus")
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a MIME -> Blob map from {@link ClipboardData} for `ClipboardItem`. */
|
||||
export function buildClipboardItems(data: ClipboardData): Record<string, Blob> {
|
||||
const items: Record<string, Blob> = {};
|
||||
if (data.text !== undefined && data.text !== "") {
|
||||
items["text/plain"] = new Blob([data.text], { type: "text/plain" });
|
||||
}
|
||||
if (data.html !== undefined && data.html !== "") {
|
||||
items["text/html"] = new Blob([data.html], { type: "text/html" });
|
||||
}
|
||||
if (data.image !== undefined) {
|
||||
const mime = data.image.type !== "" ? data.image.type : "image/png";
|
||||
items[mime] = data.image;
|
||||
}
|
||||
if (data.blobs !== undefined) {
|
||||
for (const [mime, blob] of Object.entries(data.blobs)) {
|
||||
items[mime] = blob;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a list of `ClipboardItem` and collect text / html / image / arbitrary
|
||||
* blob entries into a {@link ClipboardData}. Applies `sanitizer` to the
|
||||
* `text/html` payload when supplied.
|
||||
*/
|
||||
export async function readClipboardItems(
|
||||
items: readonly ClipboardItem[],
|
||||
sanitizer: ((html: string) => string) | undefined
|
||||
): Promise<ClipboardData> {
|
||||
let text: string | undefined;
|
||||
let html: string | undefined;
|
||||
let image: Blob | undefined;
|
||||
const blobs: Record<string, Blob> = {};
|
||||
for (const item of items) {
|
||||
for (const type of item.types) {
|
||||
const blob = await item.getType(type);
|
||||
if (type === "text/plain") {
|
||||
text = await blob.text();
|
||||
} else if (type === "text/html") {
|
||||
const raw = await blob.text();
|
||||
html = sanitizer !== undefined ? sanitizer(raw) : raw;
|
||||
} else if (type.startsWith("image/")) {
|
||||
if (image === undefined) {
|
||||
image = blob;
|
||||
}
|
||||
} else {
|
||||
blobs[type] = blob;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(text !== undefined ? { text } : {}),
|
||||
...(html !== undefined ? { html } : {}),
|
||||
...(image !== undefined ? { image } : {}),
|
||||
...(Object.keys(blobs).length > 0 ? { blobs } : {}),
|
||||
};
|
||||
}
|
||||
1
packages/foundation/data/clipboard/src/manager/index.ts
Normal file
1
packages/foundation/data/clipboard/src/manager/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClipboardManager } from "./manager.js";
|
||||
142
packages/foundation/data/clipboard/src/manager/manager.md
Normal file
142
packages/foundation/data/clipboard/src/manager/manager.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# `manager/manager.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`ClipboardManager` is the orchestrator class for `@suaveplan/clipboard`.
|
||||
It composes the navigator + window seams, the FIFO `AsyncQueue`, the
|
||||
typed error registry, the OpenTelemetry observability bundle, the
|
||||
secure-context guard, the permission probe, and the fallback path into a
|
||||
single ergonomic facade. Every public method records counters, the
|
||||
operation latency histogram, and per-code error totals so production
|
||||
deployments observe clipboard health without any caller-side wiring.
|
||||
|
||||
## Features
|
||||
|
||||
- `copyText`, `copyRichContent`, `readText`, `readRichContent` with
|
||||
consistent `ClipboardResult` returns.
|
||||
- `checkPermissions` + `onPermissionChange` for permission lifecycle.
|
||||
- `isSupported`, `isSecure`, `dispose` for environment + lifecycle.
|
||||
- Configurable serialization (`serialize: true` by default).
|
||||
- Configurable secure-context guard (`requireSecureContext`).
|
||||
- Optional sanitizer hook applied to inbound HTML.
|
||||
- Optional pre-built observability bundle.
|
||||
- Focus-aware retry on `NotAllowedError: Document is not focused`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ClipboardManager } from "@suaveplan/clipboard";
|
||||
|
||||
const clipboard = new ClipboardManager();
|
||||
const result = await clipboard.copyText("hello");
|
||||
if (!result.success) {
|
||||
console.warn("copy failed:", result.code, result.error);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
ClipboardManager,
|
||||
createClipboardObservability,
|
||||
createTagAllowlistSanitizer,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const observability = createClipboardObservability();
|
||||
const clipboard = new ClipboardManager({
|
||||
useFallback: true,
|
||||
requireSecureContext: true,
|
||||
serialize: true,
|
||||
focusRetry: true,
|
||||
sanitizer: createTagAllowlistSanitizer(["p", "b", "i", "br"]),
|
||||
pasteTimeoutMs: 2000,
|
||||
observability,
|
||||
});
|
||||
|
||||
const png = await loadPngBlob();
|
||||
await clipboard.copyRichContent({
|
||||
text: "fallback text",
|
||||
html: "<b>bold</b>",
|
||||
image: png,
|
||||
});
|
||||
|
||||
const unsub = await clipboard.onPermissionChange((snapshot, name) => {
|
||||
console.info("permission change", name, snapshot);
|
||||
});
|
||||
|
||||
// later: unsub(); await clipboard.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new ClipboardManager(options?, nav?, win?)`
|
||||
|
||||
Construct a manager. `options` is merged on top of
|
||||
`DEFAULT_CLIPBOARD_OPTIONS`. `nav` and `win` default to the global
|
||||
`navigator` and `window`; either may be supplied as a structural shape
|
||||
for SSR or unit tests.
|
||||
|
||||
### `copyText(text): Promise<ClipboardResult<string>>`
|
||||
|
||||
Copy plain text. Uses the async API when available; falls back to the
|
||||
hidden-textarea path on rejection or absence when `useFallback` is true.
|
||||
Short-circuits with `CLIPBOARD.INSECURE_CONTEXT` when
|
||||
`requireSecureContext` is set and the bound window is insecure.
|
||||
|
||||
### `copyRichContent(data): Promise<ClipboardResult<ClipboardData>>`
|
||||
|
||||
Copy rich content (text / html / image / blobs) via `ClipboardItem`. The
|
||||
fallback path requires `data.text`; rich-only payloads without text
|
||||
short-circuit with `CLIPBOARD.NO_CONTENT`. Empty payloads also surface
|
||||
`CLIPBOARD.NO_CONTENT`.
|
||||
|
||||
### `readText(): Promise<ClipboardResult<string>>`
|
||||
|
||||
Read plain text. Falls back to the paste-event capture when the API is
|
||||
unavailable or rejects (and `useFallback` is true).
|
||||
|
||||
### `readRichContent(): Promise<ClipboardResult<ClipboardData>>`
|
||||
|
||||
Read rich content. Returns `CLIPBOARD.API_UNAVAILABLE` when no async API
|
||||
is present (rich content has no usable fallback). Applies the optional
|
||||
sanitizer to inbound HTML.
|
||||
|
||||
### `checkPermissions(): Promise<PermissionResult>`
|
||||
|
||||
Snapshot of `clipboard-read` + `clipboard-write` permission state.
|
||||
|
||||
### `onPermissionChange(cb): Promise<() => void>`
|
||||
|
||||
Subscribe to permission changes. Returns an unsubscribe function.
|
||||
|
||||
### `isSupported(): boolean`
|
||||
|
||||
True when `navigator.clipboard` is defined on the bound navigator.
|
||||
|
||||
### `isSecure(): boolean`
|
||||
|
||||
True when the bound window does not flag `isSecureContext: false`.
|
||||
|
||||
### `dispose(): Promise<void>`
|
||||
|
||||
Drain the internal queue. After dispose, queued operations resolve with
|
||||
their current outcomes; new enqueues reject.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The manager keeps a single `OperationContext` reference (navigator,
|
||||
window, options, observability) shared with the pure operation functions
|
||||
in `operations.ts`. This split keeps the class under the 300-line file
|
||||
budget while preserving the public API. The internal `run` method wraps
|
||||
every operation in a latency-measuring closure and either enqueues it
|
||||
via the `AsyncQueue` (when `serialize: true`) or invokes it directly. The
|
||||
`recordResult` helper updates counters per-call and per-error-code so
|
||||
the OTel pipeline measures both the macro-shape (copy vs. read) and the
|
||||
micro-shape (which `CLIPBOARD.*` code fired). The constructor is SSR-
|
||||
safe: missing globals collapse to a fully-typed empty navigator and an
|
||||
undefined window so import-time evaluation never throws on a server.
|
||||
397
packages/foundation/data/clipboard/src/manager/manager.test.ts
Normal file
397
packages/foundation/data/clipboard/src/manager/manager.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLIPBOARD_CODES } from "../errors/errors.js";
|
||||
import { createClipboardObservability } from "../observability/observability.js";
|
||||
import type { ClipboardNavigator } from "../types/clipboard.types.js";
|
||||
import { ClipboardManager } from "./manager.js";
|
||||
|
||||
interface NavOptions {
|
||||
readonly writeText?: (text: string) => Promise<void>;
|
||||
readonly readText?: () => Promise<string>;
|
||||
readonly write?: (items: ClipboardItem[]) => Promise<void>;
|
||||
readonly read?: () => Promise<ClipboardItem[]>;
|
||||
}
|
||||
|
||||
function nav(opts: NavOptions = {}): ClipboardNavigator {
|
||||
return {
|
||||
clipboard: {
|
||||
writeText: opts.writeText ?? (async () => {}),
|
||||
readText: opts.readText ?? (async () => "default"),
|
||||
write: opts.write ?? (async () => {}),
|
||||
read: opts.read ?? (async () => []),
|
||||
},
|
||||
permissions: {
|
||||
query: async () => ({ state: "granted" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClipboardManager.copyText wiring", () => {
|
||||
it("copyText writes via Clipboard API and emits api metrics", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const writes: string[] = [];
|
||||
const m = new ClipboardManager(
|
||||
{},
|
||||
nav({
|
||||
writeText: async (t) => {
|
||||
writes.push(t);
|
||||
},
|
||||
})
|
||||
);
|
||||
const r = await m.copyText("hello");
|
||||
expect(r).toEqual({ success: true, data: "hello", method: "api" });
|
||||
expect(writes).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("copyText falls back to execCommand when API throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = document.execCommand;
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => true;
|
||||
try {
|
||||
const m = new ClipboardManager(
|
||||
{ useFallback: true },
|
||||
nav({
|
||||
writeText: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
})
|
||||
);
|
||||
const r = await m.copyText("x");
|
||||
expect(r.method).toBe("fallback");
|
||||
expect(r.success).toBe(true);
|
||||
} finally {
|
||||
document.execCommand = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("copyText reports CLIPBOARD.READ_FAILED when fallback disabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager(
|
||||
{ useFallback: false },
|
||||
nav({
|
||||
writeText: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
})
|
||||
);
|
||||
const r = await m.copyText("x");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.method).toBe("api");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
});
|
||||
|
||||
it("copyText short-circuits with INSECURE_CONTEXT when requireSecureContext is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager({ requireSecureContext: true }, nav(), {
|
||||
isSecureContext: false,
|
||||
});
|
||||
const r = await m.copyText("x");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.INSECURE_CONTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager queue serialization", () => {
|
||||
it("copyText calls execute in arrival order when serialize is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const order: string[] = [];
|
||||
const m = new ClipboardManager(
|
||||
{ serialize: true },
|
||||
nav({
|
||||
writeText: async (t) => {
|
||||
if (t === "A") {
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
} else if (t === "B") {
|
||||
await new Promise<void>((r) => setTimeout(r, 5));
|
||||
}
|
||||
order.push(t);
|
||||
},
|
||||
})
|
||||
);
|
||||
await Promise.all([m.copyText("A"), m.copyText("B"), m.copyText("C")]);
|
||||
expect(order).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("copyText calls run concurrently when serialize is false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const m = new ClipboardManager(
|
||||
{ serialize: false },
|
||||
nav({
|
||||
writeText: async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise<void>((r) => setTimeout(r, 10));
|
||||
inFlight -= 1;
|
||||
},
|
||||
})
|
||||
);
|
||||
await Promise.all([m.copyText("a"), m.copyText("b")]);
|
||||
expect(maxInFlight).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager focus retry", () => {
|
||||
it("retries once on NotAllowedError 'document not focused'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const m = new ClipboardManager(
|
||||
{ focusRetry: true, useFallback: false },
|
||||
nav({
|
||||
writeText: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
const err = new Error("Document is not focused.");
|
||||
err.name = "NotAllowedError";
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
isSecureContext: true,
|
||||
addEventListener: (_type: "focus", cb: () => void): void => {
|
||||
setTimeout(cb, 5);
|
||||
},
|
||||
removeEventListener: (): void => {},
|
||||
}
|
||||
);
|
||||
const r = await m.copyText("retry");
|
||||
expect(r.success).toBe(true);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("fails after focus retry exhaustion", { timeout: 5000 }, async () => {
|
||||
const m = new ClipboardManager(
|
||||
{ focusRetry: true, useFallback: false },
|
||||
nav({
|
||||
writeText: async () => {
|
||||
const err = new Error("Document is not focused.");
|
||||
err.name = "NotAllowedError";
|
||||
throw err;
|
||||
},
|
||||
}),
|
||||
{
|
||||
isSecureContext: true,
|
||||
addEventListener: (_type: "focus", cb: () => void): void => {
|
||||
setTimeout(cb, 5);
|
||||
},
|
||||
removeEventListener: (): void => {},
|
||||
}
|
||||
);
|
||||
const r = await m.copyText("x");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager.readText", () => {
|
||||
it("reads via API", { timeout: 5000 }, async () => {
|
||||
const m = new ClipboardManager(
|
||||
{},
|
||||
nav({ readText: async () => "clip" })
|
||||
);
|
||||
const r = await m.readText();
|
||||
expect(r).toEqual({ success: true, data: "clip", method: "api" });
|
||||
});
|
||||
|
||||
it("returns READ_FAILED when API rejects and fallback disabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager(
|
||||
{ useFallback: false },
|
||||
nav({
|
||||
readText: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
})
|
||||
);
|
||||
const r = await m.readText();
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
expect(r.method).toBe("api");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager rich-content delegation", () => {
|
||||
it("copyRichContent delegates through the manager.run pipeline", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager({}, nav());
|
||||
const r = await m.copyRichContent({ text: "x" });
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.method).toBe("api");
|
||||
});
|
||||
|
||||
it("readRichContent delegates through the manager.run pipeline", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager({}, nav());
|
||||
const r = await m.readRichContent();
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager observability", () => {
|
||||
it("clipboard_error_total advances when copyText fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager(
|
||||
{ useFallback: false },
|
||||
{ clipboard: undefined }
|
||||
);
|
||||
const obs = (
|
||||
m as unknown as {
|
||||
obs: {
|
||||
counters: {
|
||||
errorTotal: {
|
||||
add: (n: number, a: object) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).obs;
|
||||
const adds: object[] = [];
|
||||
const original = obs.counters.errorTotal.add.bind(
|
||||
obs.counters.errorTotal
|
||||
);
|
||||
obs.counters.errorTotal.add = (n: number, a: object) => {
|
||||
adds.push(a);
|
||||
original(n, a);
|
||||
};
|
||||
await m.copyText("x");
|
||||
expect(
|
||||
adds.some(
|
||||
(a) =>
|
||||
(a as { code?: string }).code ===
|
||||
CLIPBOARD_CODES.API_UNAVAILABLE
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clipboard_op_latency_ms records observations for copyText", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager({}, nav());
|
||||
const obs = (
|
||||
m as unknown as {
|
||||
obs: {
|
||||
histograms: {
|
||||
opLatencyMs: {
|
||||
record: (ms: number, attrs: object) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).obs;
|
||||
const observations: Array<{ ms: number; attrs: object }> = [];
|
||||
const original = obs.histograms.opLatencyMs.record.bind(
|
||||
obs.histograms.opLatencyMs
|
||||
);
|
||||
obs.histograms.opLatencyMs.record = (ms: number, attrs: object) => {
|
||||
observations.push({ ms, attrs });
|
||||
original(ms, attrs);
|
||||
};
|
||||
await m.copyText("x");
|
||||
expect(observations.length).toBeGreaterThanOrEqual(1);
|
||||
expect((observations[0]?.attrs as { op: string }).op).toBe("copyText");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager.checkPermissions + onPermissionChange", () => {
|
||||
it("checkPermissions returns granted state", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager({}, nav());
|
||||
const r = await m.checkPermissions();
|
||||
expect(r).toEqual({ read: "granted", write: "granted" });
|
||||
});
|
||||
|
||||
it("onPermissionChange returns an unsubscribe function", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const m = new ClipboardManager({}, nav());
|
||||
const unsub = await m.onPermissionChange(() => {});
|
||||
expect(typeof unsub).toBe("function");
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClipboardManager misc", () => {
|
||||
it("options merge with DEFAULT_CLIPBOARD_OPTIONS", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new ClipboardManager({ useFallback: false }, nav());
|
||||
expect(m.isSupported()).toBe(true);
|
||||
expect(m.isSecure()).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a pre-built observability bundle", { timeout: 5000 }, () => {
|
||||
const observability = createClipboardObservability();
|
||||
const m = new ClipboardManager({ observability }, nav());
|
||||
expect(m.isSupported()).toBe(true);
|
||||
});
|
||||
|
||||
it("manager survives missing navigator global", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = (globalThis as unknown as { navigator?: Navigator })
|
||||
.navigator;
|
||||
(globalThis as unknown as { navigator?: Navigator }).navigator =
|
||||
undefined;
|
||||
try {
|
||||
const m = new ClipboardManager();
|
||||
expect(m.isSupported()).toBe(false);
|
||||
} finally {
|
||||
(globalThis as unknown as { navigator?: Navigator }).navigator =
|
||||
original;
|
||||
}
|
||||
});
|
||||
|
||||
it("isSupported returns false when clipboard absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = new ClipboardManager({}, { clipboard: undefined });
|
||||
expect(m.isSupported()).toBe(false);
|
||||
});
|
||||
|
||||
it("isSecure delegates to window", { timeout: 5000 }, () => {
|
||||
const m = new ClipboardManager({}, nav(), { isSecureContext: false });
|
||||
expect(m.isSecure()).toBe(false);
|
||||
});
|
||||
|
||||
it("dispose drains queue", { timeout: 5000 }, async () => {
|
||||
const m = new ClipboardManager({}, nav());
|
||||
await m.dispose();
|
||||
});
|
||||
|
||||
it("ClipboardManager constructor is SSR-safe", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const originalDoc = globalThis.document;
|
||||
const originalNav = (globalThis as unknown as { navigator?: Navigator })
|
||||
.navigator;
|
||||
const originalWin = (globalThis as unknown as { window?: Window })
|
||||
.window;
|
||||
(globalThis as unknown as { document?: Document }).document = undefined;
|
||||
(globalThis as unknown as { navigator?: Navigator }).navigator =
|
||||
undefined;
|
||||
(globalThis as unknown as { window?: Window }).window = undefined;
|
||||
try {
|
||||
const m = new ClipboardManager();
|
||||
expect(m.isSupported()).toBe(false);
|
||||
expect(m.isSecure()).toBe(true);
|
||||
} finally {
|
||||
(globalThis as unknown as { document?: Document }).document =
|
||||
originalDoc;
|
||||
(globalThis as unknown as { navigator?: Navigator }).navigator =
|
||||
originalNav;
|
||||
(globalThis as unknown as { window?: Window }).window = originalWin;
|
||||
}
|
||||
});
|
||||
});
|
||||
144
packages/foundation/data/clipboard/src/manager/manager.ts
Normal file
144
packages/foundation/data/clipboard/src/manager/manager.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { createClipboardObservability } from "../observability/observability.js";
|
||||
import type { ClipboardObservability } from "../observability/observability.types.js";
|
||||
import {
|
||||
checkPermissions,
|
||||
onPermissionChange,
|
||||
} from "../permissions/permissions.js";
|
||||
import { AsyncQueue } from "../queue/queue.js";
|
||||
import { isSecure } from "../security/security.js";
|
||||
import type {
|
||||
ClipboardData,
|
||||
ClipboardNavigator,
|
||||
ClipboardOptions,
|
||||
ClipboardResult,
|
||||
ClipboardWindowLike,
|
||||
PermissionChangeListener,
|
||||
PermissionResult,
|
||||
} from "../types/clipboard.types.js";
|
||||
import { DEFAULT_CLIPBOARD_OPTIONS } from "../types/clipboard.types.js";
|
||||
import { resolveNavigator, resolveWindow } from "./helpers.js";
|
||||
import {
|
||||
type OperationContext,
|
||||
runCopyRich,
|
||||
runCopyText,
|
||||
runReadRich,
|
||||
runReadText,
|
||||
} from "./operations.js";
|
||||
|
||||
type Op = "copyText" | "readText" | "copyRich" | "readRich";
|
||||
|
||||
/**
|
||||
* Enterprise ClipboardManager. Owns a navigator + options bag, an internal
|
||||
* `AsyncQueue` for FIFO serialization, an observability bundle for OTel
|
||||
* instrumentation, and the fallback path. Every public method records
|
||||
* counters, histograms, and per-code error totals.
|
||||
*/
|
||||
export class ClipboardManager {
|
||||
private readonly options: ClipboardOptions;
|
||||
private readonly nav: ClipboardNavigator;
|
||||
private readonly win: ClipboardWindowLike | undefined;
|
||||
private readonly queue: AsyncQueue;
|
||||
private readonly obs: ClipboardObservability;
|
||||
private readonly ctx: OperationContext;
|
||||
|
||||
constructor(
|
||||
options: Partial<ClipboardOptions> = {},
|
||||
nav?: ClipboardNavigator,
|
||||
win?: ClipboardWindowLike
|
||||
) {
|
||||
this.options = { ...DEFAULT_CLIPBOARD_OPTIONS, ...options };
|
||||
this.nav = resolveNavigator(nav);
|
||||
this.win = resolveWindow(win);
|
||||
this.queue = new AsyncQueue();
|
||||
this.obs = createClipboardObservability(
|
||||
this.options.observability !== undefined
|
||||
? { observability: this.options.observability }
|
||||
: undefined
|
||||
);
|
||||
this.ctx = {
|
||||
nav: this.nav,
|
||||
win: this.win,
|
||||
options: this.options,
|
||||
obs: this.obs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the async Clipboard API is supported by the bound navigator. */
|
||||
isSupported(): boolean {
|
||||
return this.nav.clipboard !== undefined;
|
||||
}
|
||||
|
||||
/** Whether the bound window is a secure context. */
|
||||
isSecure(): boolean {
|
||||
return isSecure(this.win);
|
||||
}
|
||||
|
||||
/** Copy plain text. */
|
||||
copyText(text: string): Promise<ClipboardResult<string>> {
|
||||
return this.run("copyText", () => runCopyText(this.ctx, text));
|
||||
}
|
||||
|
||||
/** Copy rich content (text / html / image / blobs). */
|
||||
copyRichContent(
|
||||
data: ClipboardData
|
||||
): Promise<ClipboardResult<ClipboardData>> {
|
||||
return this.run("copyRich", () => runCopyRich(this.ctx, data));
|
||||
}
|
||||
|
||||
/** Read plain text; paste-event fallback when API absent. */
|
||||
readText(): Promise<ClipboardResult<string>> {
|
||||
return this.run("readText", () => runReadText(this.ctx));
|
||||
}
|
||||
|
||||
/** Read rich content (text / html / image / blobs). */
|
||||
readRichContent(): Promise<ClipboardResult<ClipboardData>> {
|
||||
return this.run("readRich", () => runReadRich(this.ctx));
|
||||
}
|
||||
|
||||
/** Probe current permission state for read + write. */
|
||||
checkPermissions(): Promise<PermissionResult> {
|
||||
return checkPermissions(this.nav);
|
||||
}
|
||||
|
||||
/** Subscribe to permission state changes. */
|
||||
onPermissionChange(cb: PermissionChangeListener): Promise<() => void> {
|
||||
return onPermissionChange(this.nav, cb);
|
||||
}
|
||||
|
||||
/** Drain the queue. */
|
||||
async dispose(): Promise<void> {
|
||||
await this.queue.drain();
|
||||
}
|
||||
|
||||
private async run<T>(
|
||||
op: Op,
|
||||
fn: () => Promise<ClipboardResult<T>>
|
||||
): Promise<ClipboardResult<T>> {
|
||||
const start = Date.now();
|
||||
const exec = async (): Promise<ClipboardResult<T>> => {
|
||||
const result = await fn();
|
||||
this.recordResult(op, result);
|
||||
this.obs.histograms.opLatencyMs.record(Date.now() - start, {
|
||||
op,
|
||||
method: result.method,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
if (this.options.serialize) {
|
||||
return this.queue.enqueue(exec);
|
||||
}
|
||||
return exec();
|
||||
}
|
||||
|
||||
private recordResult(op: Op, result: ClipboardResult<unknown>): void {
|
||||
const kind = op === "copyText" || op === "readText" ? "text" : "rich";
|
||||
const isCopy = op === "copyText" || op === "copyRich";
|
||||
const counter = isCopy
|
||||
? this.obs.counters.copyTotal
|
||||
: this.obs.counters.readTotal;
|
||||
counter.add(1, { method: result.method, kind });
|
||||
if (!result.success && result.code !== undefined) {
|
||||
this.obs.counters.errorTotal.add(1, { code: result.code });
|
||||
}
|
||||
}
|
||||
}
|
||||
113
packages/foundation/data/clipboard/src/manager/operations.md
Normal file
113
packages/foundation/data/clipboard/src/manager/operations.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# `manager/operations.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure operation functions invoked by `ClipboardManager`. Each function
|
||||
takes an `OperationContext` (navigator, window, options, observability)
|
||||
plus the operation-specific arguments and returns a `ClipboardResult`.
|
||||
Splitting these out of the class keeps `manager.ts` under the file-size
|
||||
budget and makes each path independently testable without instantiating a
|
||||
full manager.
|
||||
|
||||
## Features
|
||||
|
||||
- `runCopyText(ctx, text)` — plain-text copy with secure-context gate,
|
||||
API path, fallback path, and focus-aware retry.
|
||||
- `runCopyRich(ctx, data)` — rich-content copy via `ClipboardItem` with
|
||||
fallback-to-text behaviour and `CLIPBOARD.NO_CONTENT` guards.
|
||||
- `runReadText(ctx)` — plain-text read with paste-event fallback.
|
||||
- `runReadRich(ctx)` — rich-content read with sanitizer pass-through.
|
||||
- Internal `tryApi` wraps every navigator call in a focus-retry-capable
|
||||
envelope.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
runCopyText,
|
||||
type OperationContext,
|
||||
createClipboardObservability,
|
||||
DEFAULT_CLIPBOARD_OPTIONS,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const ctx: OperationContext = {
|
||||
nav: navigator,
|
||||
win: window,
|
||||
options: DEFAULT_CLIPBOARD_OPTIONS,
|
||||
obs: createClipboardObservability(),
|
||||
};
|
||||
const result = await runCopyText(ctx, "hello");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
runCopyRich,
|
||||
type OperationContext,
|
||||
DEFAULT_CLIPBOARD_OPTIONS,
|
||||
createClipboardObservability,
|
||||
createTagAllowlistSanitizer,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const ctx: OperationContext = {
|
||||
nav: navigator,
|
||||
win: window,
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
sanitizer: createTagAllowlistSanitizer(["p", "b", "i"]),
|
||||
requireSecureContext: true,
|
||||
},
|
||||
obs: createClipboardObservability(),
|
||||
};
|
||||
|
||||
const result = await runCopyRich(ctx, {
|
||||
text: "fallback text",
|
||||
html: "<b>x</b>",
|
||||
image: await fetch("/logo.png").then((r) => r.blob()),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OperationContext`
|
||||
|
||||
`{ nav, win, options, obs }`. Lifetime-bound to the manager.
|
||||
|
||||
### `runCopyText(ctx, text)`
|
||||
|
||||
Returns `Promise<ClipboardResult<string>>`. Honours
|
||||
`requireSecureContext`, `useFallback`, `focusRetry`, and the fallback
|
||||
counter wiring.
|
||||
|
||||
### `runCopyRich(ctx, data)`
|
||||
|
||||
Returns `Promise<ClipboardResult<ClipboardData>>`. Builds a single
|
||||
`ClipboardItem` covering every populated MIME slot. Falls back to
|
||||
`runCopyText` when the API rejects and `data.text` is non-empty.
|
||||
|
||||
### `runReadText(ctx)`
|
||||
|
||||
Returns `Promise<ClipboardResult<string>>`. Uses `readTextFallback` from
|
||||
the `fallback/` module when the API is absent or rejects.
|
||||
|
||||
### `runReadRich(ctx)`
|
||||
|
||||
Returns `Promise<ClipboardResult<ClipboardData>>`. Walks every returned
|
||||
`ClipboardItem` and populates `text` / `html` / `image` / `blobs`. The
|
||||
first image wins when multiple are present. The optional sanitizer
|
||||
mutates `html` before returning.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The functions are exported (not module-private) so the test suite can
|
||||
exercise each branch in isolation without driving a full `ClipboardManager`.
|
||||
The `tryApi` helper centralises the focus-retry logic: a single
|
||||
`NotAllowedError` whose message mentions "focus" triggers one
|
||||
`waitForNextFocus` + retry. Any other rejection skips the retry path and
|
||||
lifts the cause via `asClipboardError(CLIPBOARD.READ_FAILED, ...)`. The
|
||||
fallback counter (`clipboard_fallback_used_total`) is incremented with a
|
||||
`reason` attribute (`"api_threw"`, `"no_api"`, `"fallback_failed"`) at the
|
||||
exact transition point so operators can distinguish "fallback because API
|
||||
returned no" from "fallback because API rejected" — a critical signal
|
||||
when diagnosing partial outages.
|
||||
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLIPBOARD_CODES } from "../errors/errors.js";
|
||||
import { createClipboardObservability } from "../observability/observability.js";
|
||||
import type {
|
||||
ClipboardData,
|
||||
ClipboardNavigator,
|
||||
} from "../types/clipboard.types.js";
|
||||
import { DEFAULT_CLIPBOARD_OPTIONS } from "../types/clipboard.types.js";
|
||||
import {
|
||||
type OperationContext,
|
||||
runCopyRich,
|
||||
runCopyText,
|
||||
runReadRich,
|
||||
runReadText,
|
||||
} from "./operations.js";
|
||||
|
||||
class FakeClipboardItem {
|
||||
public readonly types: string[];
|
||||
constructor(public readonly items: Record<string, Blob>) {
|
||||
this.types = Object.keys(items);
|
||||
}
|
||||
async getType(type: string): Promise<Blob> {
|
||||
const blob = this.items[type];
|
||||
if (blob === undefined) {
|
||||
throw new Error(`no ${type}`);
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
globalThis as unknown as { ClipboardItem: typeof ClipboardItem }
|
||||
).ClipboardItem = FakeClipboardItem as unknown as typeof ClipboardItem;
|
||||
|
||||
interface NavOptions {
|
||||
readonly writeText?: (text: string) => Promise<void>;
|
||||
readonly readText?: () => Promise<string>;
|
||||
readonly write?: (items: ClipboardItem[]) => Promise<void>;
|
||||
readonly read?: () => Promise<ClipboardItem[]>;
|
||||
}
|
||||
|
||||
function makeNav(opts: NavOptions = {}): ClipboardNavigator {
|
||||
return {
|
||||
clipboard: {
|
||||
writeText: opts.writeText ?? (async () => {}),
|
||||
readText: opts.readText ?? (async () => "default"),
|
||||
write: opts.write ?? (async () => {}),
|
||||
read: opts.read ?? (async () => []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
nav: overrides.nav ?? makeNav(),
|
||||
win: overrides.win,
|
||||
options: overrides.options ?? { ...DEFAULT_CLIPBOARD_OPTIONS },
|
||||
obs: overrides.obs ?? createClipboardObservability(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runCopyRich rich-content paths", () => {
|
||||
it("copyRichContent writes text html and image in one ClipboardItem", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let written: ClipboardItem[] = [];
|
||||
const png = new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: "image/png",
|
||||
});
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({
|
||||
write: async (items) => {
|
||||
written = items;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const data: ClipboardData = {
|
||||
text: "hello",
|
||||
html: "<b>hi</b>",
|
||||
image: png,
|
||||
};
|
||||
const r = await runCopyRich(ctx, data);
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.method).toBe("api");
|
||||
expect(written).toHaveLength(1);
|
||||
const item = written[0] as unknown as FakeClipboardItem;
|
||||
expect(item.types.sort()).toEqual(
|
||||
["image/png", "text/html", "text/plain"].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("copyRichContent returns NO_CONTENT for empty data", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = await runCopyRich(makeCtx(), {});
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.NO_CONTENT);
|
||||
});
|
||||
|
||||
it("copyRichContent fallback rejects html-only payload as NO_CONTENT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({
|
||||
write: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runCopyRich(ctx, { html: "<b>x</b>" });
|
||||
expect(r.method).toBe("fallback");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.NO_CONTENT);
|
||||
});
|
||||
|
||||
it("falls back via text when API write throws and useFallback true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = document.execCommand;
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => true;
|
||||
try {
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({
|
||||
write: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runCopyRich(ctx, {
|
||||
text: "t",
|
||||
html: "<b>x</b>",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.method).toBe("fallback");
|
||||
expect(r.data).toEqual({ text: "t", html: "<b>x</b>" });
|
||||
} finally {
|
||||
document.execCommand = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports READ_FAILED when api throws and fallback disabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
options: { ...DEFAULT_CLIPBOARD_OPTIONS, useFallback: false },
|
||||
nav: makeNav({
|
||||
write: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runCopyRich(ctx, { text: "t" });
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
});
|
||||
|
||||
it("short-circuits with INSECURE_CONTEXT when requireSecureContext is true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
requireSecureContext: true,
|
||||
},
|
||||
win: { isSecureContext: false },
|
||||
});
|
||||
const r = await runCopyRich(ctx, { text: "x" });
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.INSECURE_CONTEXT);
|
||||
});
|
||||
|
||||
it("accepts arbitrary MIME blobs", { timeout: 5000 }, async () => {
|
||||
let written: ClipboardItem[] = [];
|
||||
const json = new Blob(['{"a":1}'], { type: "application/json" });
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({
|
||||
write: async (items) => {
|
||||
written = items;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runCopyRich(ctx, {
|
||||
blobs: { "application/json": json },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
const item = written[0] as unknown as FakeClipboardItem;
|
||||
expect(item.types).toContain("application/json");
|
||||
});
|
||||
|
||||
it("defaults image MIME to image/png when blob.type is empty", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let written: ClipboardItem[] = [];
|
||||
const blob = new Blob([new Uint8Array([1])], { type: "" });
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({
|
||||
write: async (items) => {
|
||||
written = items;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runCopyRich(ctx, { image: blob });
|
||||
expect(r.success).toBe(true);
|
||||
expect((written[0] as unknown as FakeClipboardItem).types).toContain(
|
||||
"image/png"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runReadRich rich-content paths", () => {
|
||||
it("readRichContent surfaces image and arbitrary blob fields", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const png = new Blob([new Uint8Array([9])], { type: "image/png" });
|
||||
const json = new Blob(['{"a":1}'], { type: "application/json" });
|
||||
const item = new FakeClipboardItem({
|
||||
"image/png": png,
|
||||
"application/json": json,
|
||||
}) as unknown as ClipboardItem;
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({ read: async () => [item] }),
|
||||
});
|
||||
const r = await runReadRich(ctx);
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.data?.image).toBeInstanceOf(Blob);
|
||||
expect(r.data?.blobs?.["application/json"]).toBeInstanceOf(Blob);
|
||||
});
|
||||
|
||||
it("readRichContent reports API_UNAVAILABLE when clipboard absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = await runReadRich(makeCtx({ nav: { clipboard: undefined } }));
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.API_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("readRichContent applies sanitizer to html payload", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const item = new FakeClipboardItem({
|
||||
"text/html": new Blob(["<p>ok</p><script>x</script>"], {
|
||||
type: "text/html",
|
||||
}),
|
||||
}) as unknown as ClipboardItem;
|
||||
const ctx = makeCtx({
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
sanitizer: (html) =>
|
||||
html.replace(/<script[\s\S]*?<\/script>/g, ""),
|
||||
},
|
||||
nav: makeNav({ read: async () => [item] }),
|
||||
});
|
||||
const r = await runReadRich(ctx);
|
||||
expect(r.data?.html).toBe("<p>ok</p>");
|
||||
});
|
||||
|
||||
it("returns READ_FAILED when read throws", { timeout: 5000 }, async () => {
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({
|
||||
read: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runReadRich(ctx);
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
});
|
||||
|
||||
it("returns text-only data when only text/plain present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const item = new FakeClipboardItem({
|
||||
"text/plain": new Blob(["only text"], { type: "text/plain" }),
|
||||
}) as unknown as ClipboardItem;
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({ read: async () => [item] }),
|
||||
});
|
||||
const r = await runReadRich(ctx);
|
||||
expect(r.data?.text).toBe("only text");
|
||||
expect(r.data?.html).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retains only the first image when multiple supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const first = new Blob([new Uint8Array([1])], { type: "image/png" });
|
||||
const second = new Blob([new Uint8Array([2])], { type: "image/jpeg" });
|
||||
const item = new FakeClipboardItem({
|
||||
"image/png": first,
|
||||
"image/jpeg": second,
|
||||
}) as unknown as ClipboardItem;
|
||||
const ctx = makeCtx({
|
||||
nav: makeNav({ read: async () => [item] }),
|
||||
});
|
||||
const r = await runReadRich(ctx);
|
||||
expect(r.data?.image).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focus retry behaviour", () => {
|
||||
it("does not retry when focus never arrives within timeout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
focusRetry: true,
|
||||
useFallback: false,
|
||||
},
|
||||
nav: makeNav({
|
||||
writeText: async () => {
|
||||
const err = new Error("Document is not focused.");
|
||||
err.name = "NotAllowedError";
|
||||
throw err;
|
||||
},
|
||||
}),
|
||||
win: {}, // No addEventListener — focus never fires.
|
||||
});
|
||||
const r = await runCopyText(ctx, "x");
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
});
|
||||
|
||||
it("returns retry failure when retry itself rejects", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let calls = 0;
|
||||
const ctx = makeCtx({
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
focusRetry: true,
|
||||
useFallback: false,
|
||||
},
|
||||
nav: makeNav({
|
||||
writeText: async () => {
|
||||
calls += 1;
|
||||
const err = new Error("Document is not focused.");
|
||||
err.name = "NotAllowedError";
|
||||
throw err;
|
||||
},
|
||||
}),
|
||||
win: {
|
||||
isSecureContext: true,
|
||||
addEventListener: (_type: "focus", cb: () => void): void => {
|
||||
setTimeout(cb, 5);
|
||||
},
|
||||
removeEventListener: (): void => {},
|
||||
},
|
||||
});
|
||||
const r = await runCopyText(ctx, "x");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCopyText / runReadText extra branches", () => {
|
||||
it("runCopyText reports API_UNAVAILABLE when no API and fallback disabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
nav: { clipboard: undefined },
|
||||
options: { ...DEFAULT_CLIPBOARD_OPTIONS, useFallback: false },
|
||||
});
|
||||
const r = await runCopyText(ctx, "x");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.API_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("runCopyText uses fallback when no API and fallback enabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = document.execCommand;
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => true;
|
||||
try {
|
||||
const ctx = makeCtx({ nav: { clipboard: undefined } });
|
||||
const r = await runCopyText(ctx, "x");
|
||||
expect(r.method).toBe("fallback");
|
||||
expect(r.success).toBe(true);
|
||||
} finally {
|
||||
document.execCommand = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("runReadText falls back via paste event when API absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
nav: { clipboard: undefined },
|
||||
options: { ...DEFAULT_CLIPBOARD_OPTIONS, pasteTimeoutMs: 20 },
|
||||
});
|
||||
const r = await runReadText(ctx);
|
||||
expect(r.method).toBe("fallback");
|
||||
// Timed out, so READ_FAILED.
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.READ_FAILED);
|
||||
});
|
||||
|
||||
it("runReadText falls back when API rejects and fallback enabled", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
options: { ...DEFAULT_CLIPBOARD_OPTIONS, pasteTimeoutMs: 20 },
|
||||
nav: makeNav({
|
||||
readText: async () => {
|
||||
throw new Error("denied");
|
||||
},
|
||||
}),
|
||||
});
|
||||
const r = await runReadText(ctx);
|
||||
expect(r.method).toBe("fallback");
|
||||
});
|
||||
|
||||
it("runCopyRich falls back to text path when ClipboardItem is undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = (
|
||||
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
|
||||
).ClipboardItem;
|
||||
(
|
||||
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
|
||||
).ClipboardItem = undefined;
|
||||
const originalExec = document.execCommand;
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => true;
|
||||
try {
|
||||
const ctx = makeCtx();
|
||||
const r = await runCopyRich(ctx, { text: "fb" });
|
||||
expect(r.method).toBe("fallback");
|
||||
expect(r.success).toBe(true);
|
||||
} finally {
|
||||
(
|
||||
globalThis as unknown as {
|
||||
ClipboardItem?: typeof ClipboardItem;
|
||||
}
|
||||
).ClipboardItem = original;
|
||||
document.execCommand = originalExec;
|
||||
}
|
||||
});
|
||||
|
||||
it("runCopyRich fallback returns error fields when text fallback fails", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const original = (
|
||||
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
|
||||
).ClipboardItem;
|
||||
(
|
||||
globalThis as unknown as { ClipboardItem?: typeof ClipboardItem }
|
||||
).ClipboardItem = undefined;
|
||||
const originalExec = document.execCommand;
|
||||
(document as unknown as { execCommand: () => boolean }).execCommand =
|
||||
() => false;
|
||||
try {
|
||||
const ctx = makeCtx();
|
||||
const r = await runCopyRich(ctx, { text: "fb" });
|
||||
expect(r.success).toBe(false);
|
||||
expect(r.method).toBe("fallback");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
|
||||
} finally {
|
||||
(
|
||||
globalThis as unknown as {
|
||||
ClipboardItem?: typeof ClipboardItem;
|
||||
}
|
||||
).ClipboardItem = original;
|
||||
document.execCommand = originalExec;
|
||||
}
|
||||
});
|
||||
|
||||
it("runCopyText short-circuits with INSECURE_CONTEXT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = makeCtx({
|
||||
options: {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
requireSecureContext: true,
|
||||
},
|
||||
win: { isSecureContext: false },
|
||||
});
|
||||
const r = await runCopyText(ctx, "x");
|
||||
expect(r.code).toBe(CLIPBOARD_CODES.INSECURE_CONTEXT);
|
||||
});
|
||||
});
|
||||
228
packages/foundation/data/clipboard/src/manager/operations.ts
Normal file
228
packages/foundation/data/clipboard/src/manager/operations.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
asClipboardError,
|
||||
CLIPBOARD_CODES,
|
||||
type ClipboardError,
|
||||
} from "../errors/errors.js";
|
||||
import { copyTextFallback, readTextFallback } from "../fallback/fallback.js";
|
||||
import type { ClipboardObservability } from "../observability/observability.types.js";
|
||||
import { waitForNextFocus } from "../security/security.js";
|
||||
import type {
|
||||
ClipboardData,
|
||||
ClipboardNavigator,
|
||||
ClipboardOptions,
|
||||
ClipboardResult,
|
||||
ClipboardWindowLike,
|
||||
} from "../types/clipboard.types.js";
|
||||
import {
|
||||
buildClipboardItems,
|
||||
failure,
|
||||
isFocusError,
|
||||
readClipboardItems,
|
||||
} from "./helpers.js";
|
||||
|
||||
/** Per-call context the operation functions receive from {@link ClipboardManager}. */
|
||||
export interface OperationContext {
|
||||
readonly nav: ClipboardNavigator;
|
||||
readonly win: ClipboardWindowLike | undefined;
|
||||
readonly options: ClipboardOptions;
|
||||
readonly obs: ClipboardObservability;
|
||||
}
|
||||
|
||||
type TryApiResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: ClipboardError };
|
||||
|
||||
async function tryApi<T>(
|
||||
ctx: OperationContext,
|
||||
invoke: () => Promise<T>
|
||||
): Promise<TryApiResult<T>> {
|
||||
try {
|
||||
const value = await invoke();
|
||||
return { ok: true, value };
|
||||
} catch (cause) {
|
||||
if (ctx.options.focusRetry && isFocusError(cause)) {
|
||||
const focused = await waitForNextFocus(ctx.win, 250);
|
||||
if (focused) {
|
||||
try {
|
||||
const value = await invoke();
|
||||
return { ok: true, value };
|
||||
} catch (retryCause) {
|
||||
return {
|
||||
ok: false,
|
||||
error: asClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
retryCause,
|
||||
"Clipboard API rejected after focus retry."
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: asClipboardError(
|
||||
CLIPBOARD_CODES.READ_FAILED,
|
||||
cause,
|
||||
"Clipboard API rejected."
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isSecureCtx(ctx: OperationContext): boolean {
|
||||
return ctx.win === undefined || ctx.win.isSecureContext !== false;
|
||||
}
|
||||
|
||||
function getClipboard(
|
||||
ctx: OperationContext
|
||||
): NonNullable<OperationContext["nav"]["clipboard"]> | undefined {
|
||||
return ctx.nav.clipboard;
|
||||
}
|
||||
|
||||
/** Run a plain-text copy with full API + fallback + secure-context handling. */
|
||||
export async function runCopyText(
|
||||
ctx: OperationContext,
|
||||
text: string
|
||||
): Promise<ClipboardResult<string>> {
|
||||
if (ctx.options.requireSecureContext && !isSecureCtx(ctx)) {
|
||||
return failure(
|
||||
CLIPBOARD_CODES.INSECURE_CONTEXT,
|
||||
"Refusing clipboard copy in an insecure context.",
|
||||
"api"
|
||||
);
|
||||
}
|
||||
const clipboard = getClipboard(ctx);
|
||||
if (clipboard !== undefined) {
|
||||
const apiResult = await tryApi(ctx, () => clipboard.writeText(text));
|
||||
if (apiResult.ok) {
|
||||
return { success: true, data: text, method: "api" };
|
||||
}
|
||||
if (!ctx.options.useFallback) {
|
||||
return {
|
||||
success: false,
|
||||
error: apiResult.error.message,
|
||||
code: CLIPBOARD_CODES.READ_FAILED,
|
||||
method: "api",
|
||||
};
|
||||
}
|
||||
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "api_threw" });
|
||||
} else if (!ctx.options.useFallback) {
|
||||
return failure(
|
||||
CLIPBOARD_CODES.API_UNAVAILABLE,
|
||||
"navigator.clipboard is undefined.",
|
||||
"api"
|
||||
);
|
||||
} else {
|
||||
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "no_api" });
|
||||
}
|
||||
return copyTextFallback(text);
|
||||
}
|
||||
|
||||
/** Run a rich-content copy via `ClipboardItem`. */
|
||||
export async function runCopyRich(
|
||||
ctx: OperationContext,
|
||||
data: ClipboardData
|
||||
): Promise<ClipboardResult<ClipboardData>> {
|
||||
if (ctx.options.requireSecureContext && !isSecureCtx(ctx)) {
|
||||
return failure(
|
||||
CLIPBOARD_CODES.INSECURE_CONTEXT,
|
||||
"Refusing clipboard copy in an insecure context.",
|
||||
"api"
|
||||
);
|
||||
}
|
||||
const items = buildClipboardItems(data);
|
||||
if (Object.keys(items).length === 0) {
|
||||
return failure(
|
||||
CLIPBOARD_CODES.NO_CONTENT,
|
||||
"copyRichContent called with empty ClipboardData.",
|
||||
"api"
|
||||
);
|
||||
}
|
||||
const clipboard = getClipboard(ctx);
|
||||
if (clipboard !== undefined && typeof ClipboardItem !== "undefined") {
|
||||
const apiResult = await tryApi(ctx, () =>
|
||||
clipboard.write([new ClipboardItem(items)])
|
||||
);
|
||||
if (apiResult.ok) {
|
||||
return { success: true, data, method: "api" };
|
||||
}
|
||||
if (!ctx.options.useFallback) {
|
||||
return {
|
||||
success: false,
|
||||
error: apiResult.error.message,
|
||||
code: CLIPBOARD_CODES.READ_FAILED,
|
||||
method: "api",
|
||||
};
|
||||
}
|
||||
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "api_threw" });
|
||||
}
|
||||
if (data.text === undefined || data.text === "") {
|
||||
return failure(
|
||||
CLIPBOARD_CODES.NO_CONTENT,
|
||||
"Rich-content fallback requires data.text.",
|
||||
"fallback"
|
||||
);
|
||||
}
|
||||
const fb = copyTextFallback(data.text);
|
||||
const result: ClipboardResult<ClipboardData> = {
|
||||
success: fb.success,
|
||||
...(fb.success ? { data } : {}),
|
||||
...(fb.error !== undefined ? { error: fb.error } : {}),
|
||||
...(fb.code !== undefined ? { code: fb.code } : {}),
|
||||
method: "fallback",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Run a plain-text read with paste-event fallback. */
|
||||
export async function runReadText(
|
||||
ctx: OperationContext
|
||||
): Promise<ClipboardResult<string>> {
|
||||
const clipboard = getClipboard(ctx);
|
||||
if (clipboard !== undefined) {
|
||||
const apiResult = await tryApi(ctx, () => clipboard.readText());
|
||||
if (apiResult.ok) {
|
||||
return { success: true, data: apiResult.value, method: "api" };
|
||||
}
|
||||
if (!ctx.options.useFallback) {
|
||||
return {
|
||||
success: false,
|
||||
error: apiResult.error.message,
|
||||
code: CLIPBOARD_CODES.READ_FAILED,
|
||||
method: "api",
|
||||
};
|
||||
}
|
||||
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "api_threw" });
|
||||
} else {
|
||||
ctx.obs.counters.fallbackUsedTotal.add(1, { reason: "no_api" });
|
||||
}
|
||||
return readTextFallback(ctx.options.pasteTimeoutMs);
|
||||
}
|
||||
|
||||
/** Run a rich-content read; returns API_UNAVAILABLE when navigator.clipboard absent. */
|
||||
export async function runReadRich(
|
||||
ctx: OperationContext
|
||||
): Promise<ClipboardResult<ClipboardData>> {
|
||||
const clipboard = getClipboard(ctx);
|
||||
if (clipboard === undefined) {
|
||||
return failure(
|
||||
CLIPBOARD_CODES.API_UNAVAILABLE,
|
||||
"navigator.clipboard is undefined; rich-content read requires the async API.",
|
||||
"fallback"
|
||||
);
|
||||
}
|
||||
const apiResult = await tryApi(ctx, () => clipboard.read());
|
||||
if (!apiResult.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: apiResult.error.message,
|
||||
code: CLIPBOARD_CODES.READ_FAILED,
|
||||
method: "api",
|
||||
};
|
||||
}
|
||||
const data = await readClipboardItems(
|
||||
apiResult.value,
|
||||
ctx.options.sanitizer
|
||||
);
|
||||
return { success: true, data, method: "api" };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createClipboardObservability } from "./observability.js";
|
||||
export type {
|
||||
ClipboardCounters,
|
||||
ClipboardHistograms,
|
||||
ClipboardObservability,
|
||||
ClipboardObservabilityOptions,
|
||||
} from "./observability.types.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# `observability/observability.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrumentation bundle for `@suaveplan/clipboard`. Wires
|
||||
four counters and one histogram on a single `Meter` plus a `Tracer`,
|
||||
defaulting to the no-op providers from `@opentelemetry/api` when no
|
||||
real provider is supplied. The `ClipboardManager` records into every
|
||||
instrument across every public method so production deployments measure
|
||||
copy / read volume, fallback usage, error rate by code, and operation
|
||||
latency without any caller-side wiring beyond passing a `Meter`.
|
||||
|
||||
## Features
|
||||
|
||||
- `clipboard_copy_total{method, kind}` — counter, copy attempts.
|
||||
- `clipboard_read_total{method, kind}` — counter, read attempts.
|
||||
- `clipboard_fallback_used_total{reason}` — counter, fallback engagements.
|
||||
- `clipboard_error_total{code}` — counter, errors by `CLIPBOARD.*` code.
|
||||
- `clipboard_op_latency_ms{op, method}` — histogram, operation latency.
|
||||
- Single factory: `createClipboardObservability(opts?)`.
|
||||
- Caller may supply a pre-built bundle to skip instrument creation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ClipboardManager,
|
||||
createClipboardObservability,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const observability = createClipboardObservability();
|
||||
const clipboard = new ClipboardManager({ observability });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
ClipboardManager,
|
||||
createClipboardObservability,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const meter = metrics.getMeterProvider().getMeter("my-app", "1.0.0");
|
||||
const observability = createClipboardObservability({ meter });
|
||||
const clipboard = new ClipboardManager({ observability });
|
||||
|
||||
// Wire the meter into your OTel SDK exporter; counters fire on every
|
||||
// copy / read call without extra plumbing.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createClipboardObservability(opts?: ClipboardObservabilityOptions): ClipboardObservability`
|
||||
|
||||
Returns a bundle with the four counters and one histogram. Pass through
|
||||
the caller-supplied bundle if `opts.observability` is set; otherwise use
|
||||
the supplied `Meter` and `Tracer` (or the no-op global ones).
|
||||
|
||||
### `ClipboardObservability`
|
||||
|
||||
`{ meter, tracer, counters, histograms }` — strongly typed; counter and
|
||||
histogram entries are OpenTelemetry `Counter` and `Histogram` instances
|
||||
respectively, so consumers can call `.add(n, attrs)` and
|
||||
`.record(ms, attrs)` directly when composing higher-level metrics.
|
||||
|
||||
### `ClipboardCounters`
|
||||
|
||||
- `copyTotal` — fires on every `copyText` / `copyRichContent` call.
|
||||
- `readTotal` — fires on every `readText` / `readRichContent` call.
|
||||
- `fallbackUsedTotal` — fires whenever the fallback path runs.
|
||||
- `errorTotal` — fires once per failure, labeled by error code.
|
||||
|
||||
### `ClipboardHistograms`
|
||||
|
||||
- `opLatencyMs` — milliseconds spent inside each public method, labeled
|
||||
with the `op` name and the `method` (`api` / `fallback`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory delegates to `@suaveplan/opentelemetry`'s `createCounter` and
|
||||
`createHistogram` helpers so all instruments share the project's
|
||||
canonical OTel wiring (descriptions, units, naming convention). The
|
||||
`Meter` and `Tracer` names are pinned to the package name (`@suaveplan/clipboard`)
|
||||
so observers can filter to clipboard signals without inspecting
|
||||
attributes. When no provider is wired, `metrics.getMeter` returns the
|
||||
no-op meter from `@opentelemetry/api`, so calling `.add` / `.record` on
|
||||
the instruments is safe and cost-free. Tests assert on the instrument
|
||||
shape (callable `add` / `record`) plus the pass-through behaviour of
|
||||
`opts.observability` so consumers can swap implementations without
|
||||
forking the factory.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createClipboardObservability } from "./observability.js";
|
||||
|
||||
describe("createClipboardObservability", () => {
|
||||
it("createClipboardObservability returns wired counters and histogram", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bundle = createClipboardObservability();
|
||||
expect(bundle.meter).toBeDefined();
|
||||
expect(bundle.tracer).toBeDefined();
|
||||
expect(typeof bundle.counters.copyTotal.add).toBe("function");
|
||||
expect(typeof bundle.counters.readTotal.add).toBe("function");
|
||||
expect(typeof bundle.counters.fallbackUsedTotal.add).toBe("function");
|
||||
expect(typeof bundle.counters.errorTotal.add).toBe("function");
|
||||
expect(typeof bundle.histograms.opLatencyMs.record).toBe("function");
|
||||
});
|
||||
|
||||
it("returns the pre-built bundle when one is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pre = createClipboardObservability();
|
||||
const got = createClipboardObservability({ observability: pre });
|
||||
expect(got).toBe(pre);
|
||||
});
|
||||
|
||||
it("accepts a caller-supplied meter and tracer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const base = createClipboardObservability();
|
||||
const got = createClipboardObservability({
|
||||
meter: base.meter,
|
||||
tracer: base.tracer,
|
||||
});
|
||||
expect(got.meter).toBe(base.meter);
|
||||
expect(got.tracer).toBe(base.tracer);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import { createCounter, createHistogram } from "@suaveplan/opentelemetry";
|
||||
import type {
|
||||
ClipboardObservability,
|
||||
ClipboardObservabilityOptions,
|
||||
Meter,
|
||||
Tracer,
|
||||
} from "./observability.types.js";
|
||||
|
||||
const METER_NAME = "@suaveplan/clipboard";
|
||||
const TRACER_NAME = "@suaveplan/clipboard";
|
||||
|
||||
/**
|
||||
* Create a {@link ClipboardObservability} bundle on the supplied meter and
|
||||
* tracer, or on the no-op providers from `@opentelemetry/api` when none are
|
||||
* supplied. Safe to call from every public entrypoint even when no
|
||||
* telemetry pipeline is wired up.
|
||||
*/
|
||||
export function createClipboardObservability(
|
||||
opts?: ClipboardObservabilityOptions
|
||||
): ClipboardObservability {
|
||||
if (opts?.observability !== undefined) {
|
||||
return opts.observability;
|
||||
}
|
||||
const meter: Meter = opts?.meter ?? metrics.getMeter(METER_NAME);
|
||||
const tracer: Tracer = opts?.tracer ?? trace.getTracer(TRACER_NAME);
|
||||
return {
|
||||
meter,
|
||||
tracer,
|
||||
counters: {
|
||||
copyTotal: createCounter(
|
||||
meter,
|
||||
"clipboard_copy_total",
|
||||
"Total clipboard copy operations (success + failure)."
|
||||
),
|
||||
readTotal: createCounter(
|
||||
meter,
|
||||
"clipboard_read_total",
|
||||
"Total clipboard read operations (success + failure)."
|
||||
),
|
||||
fallbackUsedTotal: createCounter(
|
||||
meter,
|
||||
"clipboard_fallback_used_total",
|
||||
"Total times the textarea + execCommand fallback was engaged."
|
||||
),
|
||||
errorTotal: createCounter(
|
||||
meter,
|
||||
"clipboard_error_total",
|
||||
"Total clipboard errors, partitioned by error code."
|
||||
),
|
||||
},
|
||||
histograms: {
|
||||
opLatencyMs: createHistogram(
|
||||
meter,
|
||||
"clipboard_op_latency_ms",
|
||||
"Clipboard operation latency in milliseconds.",
|
||||
"ms"
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Counter, Histogram, Meter, Tracer } from "@opentelemetry/api";
|
||||
|
||||
/** Counter instruments exposed by the observability bundle. */
|
||||
export interface ClipboardCounters {
|
||||
readonly copyTotal: Counter;
|
||||
readonly readTotal: Counter;
|
||||
readonly fallbackUsedTotal: Counter;
|
||||
readonly errorTotal: Counter;
|
||||
}
|
||||
|
||||
/** Histogram instruments exposed by the observability bundle. */
|
||||
export interface ClipboardHistograms {
|
||||
readonly opLatencyMs: Histogram;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observability bundle injected into the {@link ClipboardManager}. Includes
|
||||
* the meter, tracer, and the strongly-typed instruments.
|
||||
*/
|
||||
export interface ClipboardObservability {
|
||||
readonly meter: Meter;
|
||||
readonly tracer: Tracer;
|
||||
readonly counters: ClipboardCounters;
|
||||
readonly histograms: ClipboardHistograms;
|
||||
}
|
||||
|
||||
/** Options for constructing a {@link ClipboardObservability} bundle. */
|
||||
export interface ClipboardObservabilityOptions {
|
||||
readonly meter?: Meter;
|
||||
readonly tracer?: Tracer;
|
||||
readonly observability?: ClipboardObservability;
|
||||
}
|
||||
|
||||
export type { Meter, Tracer };
|
||||
@@ -0,0 +1 @@
|
||||
export { checkPermissions, onPermissionChange } from "./permissions.js";
|
||||
@@ -0,0 +1,91 @@
|
||||
# `permissions/permissions.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Probe and subscribe to clipboard permission state via the browser's
|
||||
Permissions API. `checkPermissions` returns a snapshot of the
|
||||
`clipboard-read` and `clipboard-write` permission states with graceful
|
||||
degradation to `"prompt"` when the API is missing or a query rejects.
|
||||
`onPermissionChange` subscribes a callback to `PermissionStatus.change`
|
||||
events on both descriptors so UI surfaces can reflect real-time grants
|
||||
and revocations without polling.
|
||||
|
||||
## Features
|
||||
|
||||
- `checkPermissions(nav)` — `{ read, write }` snapshot; never throws.
|
||||
- `onPermissionChange(nav, cb)` — subscribe with one callback, unsubscribe
|
||||
with a single returned function.
|
||||
- Tolerant of partial API support: falls back to `PermissionStatus.onchange`
|
||||
when `addEventListener` is unavailable.
|
||||
- Tolerant of permission-query rejection: skips that slot without
|
||||
re-throwing.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkPermissions } from "@suaveplan/clipboard";
|
||||
|
||||
const { read, write } = await checkPermissions(navigator);
|
||||
if (read === "denied" || write === "denied") {
|
||||
showFallbackUi();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { onPermissionChange } from "@suaveplan/clipboard";
|
||||
|
||||
const unsubscribe = await onPermissionChange(
|
||||
navigator,
|
||||
(snapshot, descriptor) => {
|
||||
console.info(
|
||||
"[clipboard] permission change",
|
||||
descriptor,
|
||||
snapshot,
|
||||
);
|
||||
if (snapshot.read === "granted" && snapshot.write === "granted") {
|
||||
enableCopyButtons();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// later — on unmount / dispose
|
||||
unsubscribe();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `checkPermissions(nav: ClipboardNavigator): Promise<PermissionResult>`
|
||||
|
||||
Resolves with `{ read, write }`. Each slot is one of `"granted"`,
|
||||
`"denied"`, `"prompt"`. Returns `{ read: "prompt", write: "prompt" }`
|
||||
when `nav.permissions` is undefined, and `"prompt"` for any individual
|
||||
descriptor whose `query()` rejects or returns an unknown state.
|
||||
|
||||
### `onPermissionChange(nav, cb): Promise<() => void>`
|
||||
|
||||
Subscribes `cb` to permission changes on both `clipboard-read` and
|
||||
`clipboard-write`. Returns an unsubscribe function. The callback receives
|
||||
the latest `PermissionResult` snapshot plus the descriptor name whose
|
||||
state changed. The change handler internally re-runs `checkPermissions`
|
||||
so both `read` and `write` are always up to date on every callback.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Permissions API exposes two listener mechanisms — `addEventListener`
|
||||
and `onchange`. Some platforms (older WebKit / mobile) support only one,
|
||||
so the module attaches via `addEventListener` when available and falls
|
||||
back to assigning `onchange` otherwise. The unsubscribe function clears
|
||||
the corresponding mechanism for each subscription. The
|
||||
`checkPermissions` helper normalises unknown state strings to `"prompt"`
|
||||
so callers never have to defend against future spec values. Query
|
||||
rejection is treated as `"prompt"` rather than `"denied"` because a
|
||||
rejection typically signals "API not supported" rather than "user
|
||||
explicitly refused" — pessimistic mapping would block correct callers in
|
||||
older browsers. Tests cover both `addEventListener` and `onchange` paths,
|
||||
plus the unsubscribe contract.
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type {
|
||||
ClipboardNavigator,
|
||||
PermissionResult,
|
||||
PermissionStatusLike,
|
||||
} from "../types/clipboard.types.js";
|
||||
import { checkPermissions, onPermissionChange } from "./permissions.js";
|
||||
|
||||
interface MutableStatus {
|
||||
state: string;
|
||||
addEventListener?: (
|
||||
type: "change",
|
||||
cb: (this: PermissionStatusLike) => void
|
||||
) => void;
|
||||
removeEventListener?: (
|
||||
type: "change",
|
||||
cb: (this: PermissionStatusLike) => void
|
||||
) => void;
|
||||
onchange?: ((this: PermissionStatusLike) => void) | null;
|
||||
}
|
||||
|
||||
function makeStatus(state: string): MutableStatus & {
|
||||
fire: () => void;
|
||||
listeners: Array<() => void>;
|
||||
} {
|
||||
const listeners: Array<() => void> = [];
|
||||
return {
|
||||
state,
|
||||
listeners,
|
||||
addEventListener(_type, cb) {
|
||||
listeners.push(cb as () => void);
|
||||
},
|
||||
removeEventListener(_type, cb) {
|
||||
const idx = listeners.indexOf(cb as () => void);
|
||||
if (idx >= 0) {
|
||||
listeners.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
fire() {
|
||||
for (const l of listeners) {
|
||||
l.call(this);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkPermissions", () => {
|
||||
it("checkPermissions reflects granted and prompt states", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const nav: ClipboardNavigator = {
|
||||
permissions: {
|
||||
query: async (d) => ({
|
||||
state: d.name === "clipboard-read" ? "granted" : "prompt",
|
||||
}),
|
||||
},
|
||||
};
|
||||
const result = await checkPermissions(nav);
|
||||
expect(result).toEqual({ read: "granted", write: "prompt" });
|
||||
});
|
||||
|
||||
it("checkPermissions returns prompt defaults when API absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await checkPermissions({});
|
||||
expect(result).toEqual({ read: "prompt", write: "prompt" });
|
||||
});
|
||||
|
||||
it("checkPermissions normalises unknown states to prompt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const nav: ClipboardNavigator = {
|
||||
permissions: {
|
||||
query: async () => ({ state: "weird" }),
|
||||
},
|
||||
};
|
||||
const result = await checkPermissions(nav);
|
||||
expect(result).toEqual({ read: "prompt", write: "prompt" });
|
||||
});
|
||||
|
||||
it("checkPermissions returns prompt for rejected queries", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const nav: ClipboardNavigator = {
|
||||
permissions: {
|
||||
query: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await checkPermissions(nav);
|
||||
expect(result).toEqual({ read: "prompt", write: "prompt" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("onPermissionChange", () => {
|
||||
it("onPermissionChange delivers updates and unsubscribes cleanly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const readStatus = makeStatus("prompt");
|
||||
const writeStatus = makeStatus("prompt");
|
||||
const statuses: Record<string, MutableStatus> = {
|
||||
"clipboard-read": readStatus,
|
||||
"clipboard-write": writeStatus,
|
||||
};
|
||||
const nav: ClipboardNavigator = {
|
||||
permissions: {
|
||||
query: async (d) =>
|
||||
statuses[d.name] as unknown as PermissionStatusLike,
|
||||
},
|
||||
};
|
||||
const seen: Array<{ name: string; snapshot: PermissionResult }> = [];
|
||||
const unsub = await onPermissionChange(nav, (snapshot, name) => {
|
||||
seen.push({ snapshot, name });
|
||||
});
|
||||
readStatus.state = "granted";
|
||||
readStatus.fire();
|
||||
// The fire() call schedules an async checkPermissions; wait a tick.
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(seen.length).toBe(1);
|
||||
expect(seen[0]?.name).toBe("clipboard-read");
|
||||
expect(seen[0]?.snapshot.read).toBe("granted");
|
||||
unsub();
|
||||
readStatus.state = "denied";
|
||||
readStatus.fire();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(seen.length).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to onchange property when addEventListener absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let onchange: (() => void) | null = null;
|
||||
const status: PermissionStatusLike & { state: string } = {
|
||||
state: "prompt",
|
||||
onchange: null,
|
||||
};
|
||||
Object.defineProperty(status, "onchange", {
|
||||
get(): (() => void) | null {
|
||||
return onchange;
|
||||
},
|
||||
set(v: (() => void) | null) {
|
||||
onchange = v;
|
||||
},
|
||||
});
|
||||
const nav: ClipboardNavigator = {
|
||||
permissions: {
|
||||
query: async () => status,
|
||||
},
|
||||
};
|
||||
const events: string[] = [];
|
||||
const unsub = await onPermissionChange(nav, (_s, name) => {
|
||||
events.push(name);
|
||||
});
|
||||
expect(typeof onchange).toBe("function");
|
||||
onchange?.();
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
unsub();
|
||||
expect(onchange).toBe(null);
|
||||
});
|
||||
|
||||
it("skips statuses whose query rejects", { timeout: 5000 }, async () => {
|
||||
const nav: ClipboardNavigator = {
|
||||
permissions: {
|
||||
query: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
},
|
||||
};
|
||||
const unsub = await onPermissionChange(nav, () => {});
|
||||
expect(typeof unsub).toBe("function");
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("returns no-op unsub when permissions API absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const unsub = await onPermissionChange({}, () => {});
|
||||
expect(typeof unsub).toBe("function");
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
ClipboardNavigator,
|
||||
ClipboardPermissionState,
|
||||
PermissionChangeListener,
|
||||
PermissionResult,
|
||||
PermissionStatusLike,
|
||||
} from "../types/clipboard.types.js";
|
||||
|
||||
const STATES: ReadonlySet<ClipboardPermissionState> = new Set([
|
||||
"granted",
|
||||
"denied",
|
||||
"prompt",
|
||||
]);
|
||||
|
||||
function normalise(state: string | undefined): ClipboardPermissionState {
|
||||
return STATES.has(state as ClipboardPermissionState)
|
||||
? (state as ClipboardPermissionState)
|
||||
: "prompt";
|
||||
}
|
||||
|
||||
async function queryOne(
|
||||
nav: ClipboardNavigator,
|
||||
name: "clipboard-read" | "clipboard-write"
|
||||
): Promise<PermissionStatusLike | undefined> {
|
||||
if (nav.permissions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await nav.permissions.query({ name });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe both `clipboard-read` and `clipboard-write` permissions. Resilient
|
||||
* to missing API or query rejection — falls back to `prompt` for each slot
|
||||
* without re-throwing.
|
||||
*/
|
||||
export async function checkPermissions(
|
||||
nav: ClipboardNavigator
|
||||
): Promise<PermissionResult> {
|
||||
if (nav.permissions === undefined) {
|
||||
return { read: "prompt", write: "prompt" };
|
||||
}
|
||||
const [readStatus, writeStatus] = await Promise.all([
|
||||
queryOne(nav, "clipboard-read"),
|
||||
queryOne(nav, "clipboard-write"),
|
||||
]);
|
||||
return {
|
||||
read: normalise(readStatus?.state),
|
||||
write: normalise(writeStatus?.state),
|
||||
};
|
||||
}
|
||||
|
||||
interface SubscriptionRecord {
|
||||
readonly status: PermissionStatusLike;
|
||||
readonly onChange: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe `cb` to permission state changes on both `clipboard-read` and
|
||||
* `clipboard-write`. Returns an unsubscribe that detaches both listeners.
|
||||
*
|
||||
* The returned promise resolves with the unsubscribe function once the
|
||||
* underlying `permissions.query` calls have settled — subscribers receive
|
||||
* `cb` invocations only for subsequent state transitions.
|
||||
*/
|
||||
export async function onPermissionChange(
|
||||
nav: ClipboardNavigator,
|
||||
cb: PermissionChangeListener
|
||||
): Promise<() => void> {
|
||||
const records: SubscriptionRecord[] = [];
|
||||
const names: ReadonlyArray<"clipboard-read" | "clipboard-write"> = [
|
||||
"clipboard-read",
|
||||
"clipboard-write",
|
||||
];
|
||||
for (const name of names) {
|
||||
const status = await queryOne(nav, name);
|
||||
if (status === undefined) {
|
||||
continue;
|
||||
}
|
||||
const onChange = (): void => {
|
||||
void checkPermissions(nav).then((snapshot) => {
|
||||
cb(snapshot, name);
|
||||
});
|
||||
};
|
||||
if (status.addEventListener !== undefined) {
|
||||
status.addEventListener("change", onChange);
|
||||
} else {
|
||||
status.onchange = onChange;
|
||||
}
|
||||
records.push({ status, onChange });
|
||||
}
|
||||
return (): void => {
|
||||
for (const { status, onChange } of records) {
|
||||
if (status.removeEventListener !== undefined) {
|
||||
status.removeEventListener("change", onChange);
|
||||
}
|
||||
if (status.onchange === onChange) {
|
||||
status.onchange = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
1
packages/foundation/data/clipboard/src/queue/index.ts
Normal file
1
packages/foundation/data/clipboard/src/queue/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AsyncQueue } from "./queue.js";
|
||||
96
packages/foundation/data/clipboard/src/queue/queue.md
Normal file
96
packages/foundation/data/clipboard/src/queue/queue.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# `queue/queue.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AsyncQueue` is a minimal FIFO asynchronous queue that serializes
|
||||
operations on a single tail promise. The `ClipboardManager` uses it to
|
||||
ensure concurrent `copyText` / `readText` / `copyRichContent` /
|
||||
`readRichContent` calls execute in arrival order — without it, the
|
||||
trailing call would win non-deterministically because the browser's
|
||||
Clipboard API does not provide ordering guarantees across overlapping
|
||||
requests. The queue is intentionally tiny (one promise reference plus a
|
||||
drained flag) so it adds no measurable overhead in the common single-call
|
||||
case.
|
||||
|
||||
## Features
|
||||
|
||||
- FIFO ordering — callbacks run strictly in arrival order.
|
||||
- Rejection-tolerant — a rejected operation does not stall later ones.
|
||||
- `drain()` closes the queue and resolves once the tail settles.
|
||||
- `reset()` re-opens a drained queue (useful for tests + dispose flows).
|
||||
- Zero dependencies; no `Promise.race`, no timers, no microtask leaks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AsyncQueue } from "@suaveplan/clipboard";
|
||||
|
||||
const queue = new AsyncQueue();
|
||||
const results = await Promise.all([
|
||||
queue.enqueue(async () => "a"),
|
||||
queue.enqueue(async () => "b"),
|
||||
queue.enqueue(async () => "c"),
|
||||
]);
|
||||
// results === ["a", "b", "c"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AsyncQueue } from "@suaveplan/clipboard";
|
||||
|
||||
const queue = new AsyncQueue();
|
||||
|
||||
// Rejection does not break the queue.
|
||||
queue.enqueue(async () => {
|
||||
throw new Error("boom");
|
||||
}).catch(() => undefined);
|
||||
const next = await queue.enqueue(async () => "still works");
|
||||
|
||||
// Close the queue when shutting down.
|
||||
await queue.drain();
|
||||
|
||||
// Re-open later for a new session.
|
||||
queue.reset();
|
||||
await queue.enqueue(async () => "round two");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new AsyncQueue()`
|
||||
|
||||
Constructs a queue with an already-resolved tail.
|
||||
|
||||
### `queue.enqueue<T>(fn: () => Promise<T>): Promise<T>`
|
||||
|
||||
Append `fn` to the queue. The returned promise settles with `fn`'s
|
||||
outcome once every previously enqueued callback has settled. If the
|
||||
queue has been drained, returns a rejected promise carrying `"AsyncQueue:
|
||||
enqueue after drain"`.
|
||||
|
||||
### `queue.drain(): Promise<void>`
|
||||
|
||||
Closes the queue. Subsequent `enqueue` calls reject. Returns a promise
|
||||
that resolves once the current tail settles (success or failure).
|
||||
|
||||
### `queue.reset(): void`
|
||||
|
||||
Re-opens a drained queue. Replaces the tail with a fresh
|
||||
`Promise.resolve()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tail promise chains through `then(onFulfilled, onRejected)` with both
|
||||
arms re-invoking `fn`. This means an operation always runs after the
|
||||
previous one regardless of how the previous resolved — the rejection does
|
||||
not propagate into the queue's tracked tail (we explicitly catch via
|
||||
`.then(() => undefined, () => undefined)`). Because the tail is `unknown`-
|
||||
typed internally, the generic `T` parameter on `enqueue` is preserved end
|
||||
to end. The drained flag is checked synchronously inside `enqueue` so
|
||||
post-drain calls reject without scheduling work. The queue is private to
|
||||
the manager but exported so advanced consumers can reuse it for
|
||||
caller-defined ordering (e.g. when composing with other browser APIs).
|
||||
74
packages/foundation/data/clipboard/src/queue/queue.test.ts
Normal file
74
packages/foundation/data/clipboard/src/queue/queue.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AsyncQueue } from "./queue.js";
|
||||
|
||||
describe("AsyncQueue", () => {
|
||||
it("enqueue preserves arrival order", { timeout: 5000 }, async () => {
|
||||
const q = new AsyncQueue();
|
||||
const order: string[] = [];
|
||||
const results = await Promise.all([
|
||||
q.enqueue(async () => {
|
||||
await new Promise<void>((r) => setTimeout(r, 30));
|
||||
order.push("A");
|
||||
return "a";
|
||||
}),
|
||||
q.enqueue(async () => {
|
||||
await new Promise<void>((r) => setTimeout(r, 5));
|
||||
order.push("B");
|
||||
return "b";
|
||||
}),
|
||||
q.enqueue(async () => {
|
||||
order.push("C");
|
||||
return "c";
|
||||
}),
|
||||
]);
|
||||
expect(order).toEqual(["A", "B", "C"]);
|
||||
expect(results).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("rejection does not stall later operations", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const q = new AsyncQueue();
|
||||
const order: string[] = [];
|
||||
const p1 = q
|
||||
.enqueue(async () => {
|
||||
order.push("first");
|
||||
throw new Error("boom");
|
||||
})
|
||||
.catch((err: unknown) => err);
|
||||
const p2 = q.enqueue(async () => {
|
||||
order.push("second");
|
||||
return 42;
|
||||
});
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect((r1 as Error).message).toBe("boom");
|
||||
expect(r2).toBe(42);
|
||||
expect(order).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("drain awaits current tail", { timeout: 5000 }, async () => {
|
||||
const q = new AsyncQueue();
|
||||
let done = false;
|
||||
q.enqueue(async () => {
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
done = true;
|
||||
});
|
||||
await q.drain();
|
||||
expect(done).toBe(true);
|
||||
});
|
||||
|
||||
it("enqueue after drain rejects", { timeout: 5000 }, async () => {
|
||||
const q = new AsyncQueue();
|
||||
await q.drain();
|
||||
await expect(q.enqueue(async () => 1)).rejects.toThrow(
|
||||
/enqueue after drain/
|
||||
);
|
||||
});
|
||||
|
||||
it("reset re-opens a drained queue", { timeout: 5000 }, async () => {
|
||||
const q = new AsyncQueue();
|
||||
await q.drain();
|
||||
q.reset();
|
||||
await expect(q.enqueue(async () => "ok")).resolves.toBe("ok");
|
||||
});
|
||||
});
|
||||
44
packages/foundation/data/clipboard/src/queue/queue.ts
Normal file
44
packages/foundation/data/clipboard/src/queue/queue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Minimal asynchronous FIFO queue. Every `enqueue(fn)` chains to the
|
||||
* previously enqueued operation so callbacks execute in arrival order.
|
||||
* Rejections do not stall the queue — the next operation runs regardless
|
||||
* of the previous result.
|
||||
*/
|
||||
export class AsyncQueue {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private drained = false;
|
||||
|
||||
/**
|
||||
* Append `fn` to the queue. Returns a promise that settles with `fn`'s
|
||||
* outcome once every previously enqueued callback has settled.
|
||||
*/
|
||||
enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.drained) {
|
||||
return Promise.reject(new Error("AsyncQueue: enqueue after drain"));
|
||||
}
|
||||
// Tail is always a settled-or-resolved promise (we catch every
|
||||
// arm into `undefined` below), so a single `.then(onFulfilled)` is
|
||||
// sufficient — no rejection arm is reachable.
|
||||
const run = this.tail.then(() => fn());
|
||||
this.tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the current tail settles. Subsequent `enqueue` calls
|
||||
* after `drain()` reject — the queue is considered closed.
|
||||
*/
|
||||
drain(): Promise<void> {
|
||||
this.drained = true;
|
||||
return this.tail.then(() => undefined);
|
||||
}
|
||||
|
||||
/** Re-open a previously drained queue. Intended for tests + dispose flows. */
|
||||
reset(): void {
|
||||
this.drained = false;
|
||||
this.tail = Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createTagAllowlistSanitizer,
|
||||
identitySanitizer,
|
||||
} from "./sanitizer.js";
|
||||
@@ -0,0 +1,88 @@
|
||||
# `sanitizer/sanitizer.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Sanitizer hooks applied to inbound `text/html` clipboard payloads. The
|
||||
package ships an identity sanitizer (pass-through) plus a regex-based
|
||||
tag-allowlist sanitizer for stripping known-dangerous tags from
|
||||
untrusted clipboard HTML. Both adhere to the `ClipboardSanitizer` shape
|
||||
`(html: string) => string` so consumers can plug in DOMPurify, sanitize-html,
|
||||
or any other library without changing call sites. The package
|
||||
deliberately does not bundle a security-grade sanitizer to avoid pinning
|
||||
consumers to a specific implementation — `createTagAllowlistSanitizer` is
|
||||
suitable for hardening trusted-origin clipboard flows and is NOT a
|
||||
substitute for DOMPurify in security-critical paths.
|
||||
|
||||
## Features
|
||||
|
||||
- `identitySanitizer` — pass-through, used when no sanitizer is supplied.
|
||||
- `createTagAllowlistSanitizer(tags)` — regex-based stripper that drops
|
||||
any tag not in the supplied allowlist while preserving allowed tags
|
||||
with their attributes intact.
|
||||
- Case-insensitive tag matching.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ClipboardManager,
|
||||
createTagAllowlistSanitizer,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const sanitize = createTagAllowlistSanitizer(["p", "b", "i", "br"]);
|
||||
const clipboard = new ClipboardManager({ sanitizer: sanitize });
|
||||
const result = await clipboard.readRichContent();
|
||||
// result.data.html no longer contains <script> or <iframe>.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import DOMPurify from "dompurify";
|
||||
import { ClipboardManager } from "@suaveplan/clipboard";
|
||||
|
||||
const clipboard = new ClipboardManager({
|
||||
sanitizer: (html) =>
|
||||
DOMPurify.sanitize(html, { ALLOWED_TAGS: ["p", "a", "b", "i"] }),
|
||||
});
|
||||
|
||||
const result = await clipboard.readRichContent();
|
||||
if (result.success) {
|
||||
const cleaned = result.data?.html ?? "";
|
||||
// Pass `cleaned` to a downstream React component or DOM renderer
|
||||
// that knows how to safely interpolate sanitized HTML.
|
||||
return cleaned;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `identitySanitizer(html: string): string`
|
||||
|
||||
Pass-through; returns input unchanged. Used internally as the default
|
||||
sanitizer when no override is supplied.
|
||||
|
||||
### `createTagAllowlistSanitizer(allowed: readonly string[]): ClipboardSanitizer`
|
||||
|
||||
Returns a sanitizer that strips every HTML tag not present in `allowed`.
|
||||
Tag matching is case-insensitive; opening and closing tags are stripped
|
||||
together. Attributes on allowed tags are preserved. The function only
|
||||
removes the angle-bracketed tag itself — any content nested inside
|
||||
disallowed tags is left in place. For full DOM-level sanitization use a
|
||||
real library such as DOMPurify.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex used is intentionally conservative — it matches
|
||||
`<[a-zA-Z][a-zA-Z0-9-]*` to capture tag names and tolerates optional
|
||||
attribute payloads via `(\s[^>]*)?`. It does not attempt to handle CDATA,
|
||||
comments, or processing instructions. This matches the documented scope:
|
||||
trusted-origin clipboard hardening, not arbitrary-source HTML
|
||||
sanitization. The `Set` of lowercased allowed tags keeps lookup at O(1).
|
||||
The module is browser-tier but has no DOM dependency, so it also works
|
||||
inside service workers and Node-side SSR consumers that happen to
|
||||
process clipboard payloads on the server.
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createTagAllowlistSanitizer, identitySanitizer } from "./sanitizer.js";
|
||||
|
||||
describe("identitySanitizer", () => {
|
||||
it("returns input untouched", { timeout: 5000 }, () => {
|
||||
expect(identitySanitizer("<p>x</p>")).toBe("<p>x</p>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTagAllowlistSanitizer", () => {
|
||||
it("strips disallowed tags", { timeout: 5000 }, () => {
|
||||
const sanitize = createTagAllowlistSanitizer(["p", "b"]);
|
||||
expect(sanitize("<p>ok</p><script>alert(1)</script><b>bold</b>")).toBe(
|
||||
"<p>ok</p>alert(1)<b>bold</b>"
|
||||
);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", { timeout: 5000 }, () => {
|
||||
const sanitize = createTagAllowlistSanitizer(["P"]);
|
||||
expect(sanitize("<p>ok</p><SCRIPT>x</SCRIPT>")).toBe("<p>ok</p>x");
|
||||
});
|
||||
|
||||
it("preserves attributes on allowed tags", { timeout: 5000 }, () => {
|
||||
const sanitize = createTagAllowlistSanitizer(["a"]);
|
||||
expect(sanitize('<a href="x">go</a>')).toBe('<a href="x">go</a>');
|
||||
});
|
||||
|
||||
it("strips closing tags of disallowed elements", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sanitize = createTagAllowlistSanitizer(["p"]);
|
||||
expect(sanitize("<p>ok</p><div>x</div>")).toBe("<p>ok</p>x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ClipboardSanitizer } from "../types/clipboard.types.js";
|
||||
|
||||
/**
|
||||
* Default sanitizer — pass-through. Consumers MUST supply a real sanitizer
|
||||
* (e.g. DOMPurify) when handling untrusted clipboard HTML; the package
|
||||
* does not bundle a sanitizer to avoid pinning consumers to a specific
|
||||
* implementation.
|
||||
*/
|
||||
export const identitySanitizer: ClipboardSanitizer = (html: string): string =>
|
||||
html;
|
||||
|
||||
/**
|
||||
* Build a conservative tag-allowlist sanitizer. Strips every tag not in the
|
||||
* allowed set using a single regex pass — suitable for stripping `<script>`
|
||||
* blocks from arbitrary clipboard HTML. NOT a substitute for a real
|
||||
* HTML sanitizer in security-critical paths.
|
||||
*/
|
||||
export function createTagAllowlistSanitizer(
|
||||
allowed: readonly string[]
|
||||
): ClipboardSanitizer {
|
||||
const set = new Set(allowed.map((tag) => tag.toLowerCase()));
|
||||
return (html: string): string =>
|
||||
html.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^>]*)?>/g, (match, tag) =>
|
||||
set.has(String(tag).toLowerCase()) ? match : ""
|
||||
);
|
||||
}
|
||||
5
packages/foundation/data/clipboard/src/security/index.ts
Normal file
5
packages/foundation/data/clipboard/src/security/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
isSecure,
|
||||
requireSecureContext,
|
||||
waitForNextFocus,
|
||||
} from "./security.js";
|
||||
105
packages/foundation/data/clipboard/src/security/security.md
Normal file
105
packages/foundation/data/clipboard/src/security/security.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `security/security.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Browser-side security helpers that guard clipboard operations against
|
||||
insecure contexts and orchestrate the focus-aware retry path. The
|
||||
Clipboard API is gated by the browser on `window.isSecureContext` — calls
|
||||
from non-HTTPS origins reject with `NotAllowedError`, and the spec
|
||||
explicitly forbids the API outside secure contexts. This module exposes a
|
||||
typed `requireSecureContext` guard plus an SSR-safe `isSecure` predicate
|
||||
so call sites surface `CLIPBOARD.INSECURE_CONTEXT` rather than a generic
|
||||
DOMException. The companion `waitForNextFocus` helper resolves on the next
|
||||
`focus` event (with a timeout) so the manager can recover from
|
||||
`NotAllowedError: Document is not focused`.
|
||||
|
||||
## Features
|
||||
|
||||
- `isSecure(window?)` — SSR-safe predicate. Returns `true` outside the
|
||||
browser so universal code paths short-circuit cleanly.
|
||||
- `requireSecureContext(window?)` — throws
|
||||
`ClipboardError(CLIPBOARD.INSECURE_CONTEXT)` when the window flags
|
||||
`isSecureContext === false`.
|
||||
- `waitForNextFocus(window?, timeoutMs)` — resolves `true` on the next
|
||||
`focus` event, `false` on timeout, `false` when the window cannot
|
||||
attach listeners.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireSecureContext } from "@suaveplan/clipboard";
|
||||
|
||||
requireSecureContext(); // throws CLIPBOARD.INSECURE_CONTEXT on HTTP
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLIPBOARD_CODES,
|
||||
ClipboardError,
|
||||
isSecure,
|
||||
waitForNextFocus,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
async function copyWithFocusRetry(text: string): Promise<void> {
|
||||
if (!isSecure()) {
|
||||
throw new ClipboardError(
|
||||
CLIPBOARD_CODES.INSECURE_CONTEXT,
|
||||
"Refusing clipboard write outside HTTPS.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.name === "NotAllowedError" &&
|
||||
err.message.includes("focus")
|
||||
) {
|
||||
const focused = await waitForNextFocus(window, 500);
|
||||
if (focused) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isSecure(window?: ClipboardWindowLike): boolean`
|
||||
|
||||
Returns `true` when the supplied window does not declare
|
||||
`isSecureContext: false`. Returns `true` when no window is supplied and
|
||||
no global `window` exists (SSR safe).
|
||||
|
||||
### `requireSecureContext(window?: ClipboardWindowLike): void`
|
||||
|
||||
Throws `ClipboardError(CLIPBOARD.INSECURE_CONTEXT)` when `isSecure`
|
||||
returns false. No-op otherwise.
|
||||
|
||||
### `waitForNextFocus(window?: ClipboardWindowLike, timeoutMs: number): Promise<boolean>`
|
||||
|
||||
Subscribes to the next `focus` event on the supplied window (or the
|
||||
global) and resolves `true` when the event fires. Resolves `false` if the
|
||||
window does not expose `addEventListener` or if the timeout elapses
|
||||
first. Internally guards against duplicate resolves with a `settled`
|
||||
flag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module accepts a `ClipboardWindowLike` shape rather than the DOM `Window`
|
||||
type so tests + SSR consumers can inject minimal shims. The focus listener
|
||||
is registered with `{ once: true }` so it removes itself on first
|
||||
delivery; the timeout path additionally calls `removeEventListener`
|
||||
defensively in case the host environment ignores `{ once: true }`. The
|
||||
`settled` flag prevents the late-delivered focus event from resolving the
|
||||
promise after the timeout has already fired — a real-world race that the
|
||||
unit tests assert on.
|
||||
158
packages/foundation/data/clipboard/src/security/security.test.ts
Normal file
158
packages/foundation/data/clipboard/src/security/security.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CLIPBOARD_CODES, ClipboardError } from "../errors/errors.js";
|
||||
import {
|
||||
isSecure,
|
||||
requireSecureContext,
|
||||
waitForNextFocus,
|
||||
} from "./security.js";
|
||||
|
||||
describe("isSecure", () => {
|
||||
it("treats undefined window as secure (SSR-safe)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isSecure(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when window.isSecureContext is true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isSecure({ isSecureContext: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when window.isSecureContext is false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isSecure({ isSecureContext: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to global window when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// happy-dom global window is a secure context by default
|
||||
expect(isSecure()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireSecureContext", () => {
|
||||
it("requireSecureContext throws INSECURE_CONTEXT when window flags insecure", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
requireSecureContext({ isSecureContext: false });
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(ClipboardError);
|
||||
expect((thrown as ClipboardError).code).toBe(
|
||||
CLIPBOARD_CODES.INSECURE_CONTEXT
|
||||
);
|
||||
});
|
||||
|
||||
it("requireSecureContext returns silently for secure context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
requireSecureContext({ isSecureContext: true })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForNextFocus", () => {
|
||||
it("resolves false when window has no addEventListener", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolved = await waitForNextFocus({}, 50);
|
||||
expect(resolved).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves true when focus event fires before timeout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const listeners = new Map<string, () => void>();
|
||||
const win = {
|
||||
addEventListener: (type: string, cb: () => void) => {
|
||||
listeners.set(type, cb);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
listeners.delete(type);
|
||||
},
|
||||
};
|
||||
const promise = waitForNextFocus(win, 200);
|
||||
setTimeout(() => {
|
||||
listeners.get("focus")?.();
|
||||
}, 10);
|
||||
const resolved = await promise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves false on timeout", { timeout: 5000 }, async () => {
|
||||
const listeners = new Map<string, () => void>();
|
||||
const win = {
|
||||
addEventListener: (type: string, cb: () => void) => {
|
||||
listeners.set(type, cb);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
listeners.delete(type);
|
||||
},
|
||||
};
|
||||
const resolved = await waitForNextFocus(win, 30);
|
||||
expect(resolved).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores late focus after timeout fired", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const focusListeners: Array<() => void> = [];
|
||||
const win = {
|
||||
addEventListener: (_type: string, cb: () => void) => {
|
||||
focusListeners.push(cb);
|
||||
},
|
||||
removeEventListener: () => {
|
||||
// Keep listener registered so we can fire it post-timeout to
|
||||
// exercise the settled-guard branch in waitForNextFocus.
|
||||
},
|
||||
};
|
||||
const result = await waitForNextFocus(win, 20);
|
||||
expect(result).toBe(false);
|
||||
// Now fire focus AFTER timeout — settled flag must suppress resolve.
|
||||
for (const cb of focusListeners) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
|
||||
it("timeout firing after focus is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const focusListeners: Array<() => void> = [];
|
||||
const win = {
|
||||
addEventListener: (_type: string, cb: () => void) => {
|
||||
focusListeners.push(cb);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
const promise = waitForNextFocus(win, 30);
|
||||
setTimeout(() => focusListeners[0]?.(), 5);
|
||||
const resolved = await promise;
|
||||
expect(resolved).toBe(true);
|
||||
// Allow the 30ms timeout to fire — it must noop because settled.
|
||||
await new Promise<void>((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
it("ignores duplicate focus events", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const focusListeners: Array<() => void> = [];
|
||||
const win = {
|
||||
addEventListener: (_type: string, cb: () => void) => {
|
||||
focusListeners.push(cb);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
const promise = waitForNextFocus(win, 200);
|
||||
focusListeners[0]?.();
|
||||
focusListeners[0]?.();
|
||||
const resolved = await promise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
});
|
||||
81
packages/foundation/data/clipboard/src/security/security.ts
Normal file
81
packages/foundation/data/clipboard/src/security/security.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { CLIPBOARD_CODES, ClipboardError } from "../errors/errors.js";
|
||||
import type { ClipboardWindowLike } from "../types/clipboard.types.js";
|
||||
|
||||
/**
|
||||
* Resolve the effective window object. When no explicit window is provided
|
||||
* and a global `window` exists, use the global; otherwise return
|
||||
* `undefined`. SSR environments simply receive `undefined`.
|
||||
*/
|
||||
function resolveWindow(
|
||||
win?: ClipboardWindowLike
|
||||
): ClipboardWindowLike | undefined {
|
||||
if (win !== undefined) {
|
||||
return win;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
return window as unknown as ClipboardWindowLike;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether the host window is a secure context. SSR environments
|
||||
* (`window` undefined) return `true` because they cannot make user-driven
|
||||
* clipboard calls anyway — the SSR-safe path returns
|
||||
* `CLIPBOARD.DOCUMENT_UNAVAILABLE` from the manager.
|
||||
*/
|
||||
export function isSecure(win?: ClipboardWindowLike): boolean {
|
||||
const resolved = resolveWindow(win);
|
||||
if (resolved === undefined) {
|
||||
return true;
|
||||
}
|
||||
return resolved.isSecureContext !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw {@link ClipboardError} with code `CLIPBOARD.INSECURE_CONTEXT` when
|
||||
* the host window reports `isSecureContext === false`. No-op when the
|
||||
* window is secure or absent.
|
||||
*/
|
||||
export function requireSecureContext(win?: ClipboardWindowLike): void {
|
||||
if (!isSecure(win)) {
|
||||
throw new ClipboardError(
|
||||
CLIPBOARD_CODES.INSECURE_CONTEXT,
|
||||
"Clipboard operations require a secure context (HTTPS or localhost)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the next `focus` event on the window then resolve. Used by the
|
||||
* focus-aware retry path. Resolves immediately when the window cannot
|
||||
* supply `addEventListener` (SSR / minimal shim).
|
||||
*/
|
||||
export function waitForNextFocus(
|
||||
win: ClipboardWindowLike | undefined,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const resolved = resolveWindow(win);
|
||||
if (resolved?.addEventListener === undefined) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const onFocus = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(true);
|
||||
};
|
||||
resolved.addEventListener?.("focus", onFocus, { once: true });
|
||||
setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolved.removeEventListener?.("focus", onFocus);
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# `types/clipboard.types.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared type definitions for `@suaveplan/clipboard`. Houses every
|
||||
public-surface interface, type alias, and the `DEFAULT_CLIPBOARD_OPTIONS`
|
||||
runtime constant. Splitting types into a flat `types/` directory keeps
|
||||
the implementation modules focused on behaviour while every consumer can
|
||||
import a single canonical shape — preventing the historical drift where
|
||||
`ClipboardResult` ended up declared three times across the codebase.
|
||||
|
||||
## Features
|
||||
|
||||
- `ClipboardPermissionState` — `"granted" | "denied" | "prompt"`.
|
||||
- `ClipboardBlobMap` — readonly MIME → Blob map for arbitrary binary
|
||||
clipboard payloads.
|
||||
- `ClipboardSanitizer` — `(html: string) => string` hook applied to
|
||||
inbound rich HTML.
|
||||
- `PermissionChangeListener` — callback shape for
|
||||
`ClipboardManager.onPermissionChange`.
|
||||
- `ClipboardData` — `{ text?, html?, image?, blobs? }`.
|
||||
- `ClipboardOptions` — full options bag accepted by `ClipboardManager`.
|
||||
- `ClipboardResult<T>` — `{ success, data?, error?, code?, method }`.
|
||||
- `PermissionResult` — `{ read, write }` snapshot.
|
||||
- `PermissionStatusLike` — structural view of `PermissionStatus`.
|
||||
- `ClipboardNavigator` — structural view of `navigator.clipboard` +
|
||||
`navigator.permissions`.
|
||||
- `ClipboardWindowLike` — structural view of `window` for
|
||||
secure-context detection and focus-retry orchestration.
|
||||
- `DEFAULT_CLIPBOARD_OPTIONS` — frozen options default.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_CLIPBOARD_OPTIONS,
|
||||
type ClipboardData,
|
||||
type ClipboardOptions,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
const opts: Partial<ClipboardOptions> = {
|
||||
...DEFAULT_CLIPBOARD_OPTIONS,
|
||||
useFallback: false,
|
||||
};
|
||||
|
||||
const data: ClipboardData = {
|
||||
text: "hello",
|
||||
html: "<b>hi</b>",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ClipboardNavigator,
|
||||
ClipboardResult,
|
||||
ClipboardWindowLike,
|
||||
PermissionChangeListener,
|
||||
} from "@suaveplan/clipboard";
|
||||
|
||||
// Compose a server-side shim used by integration tests:
|
||||
const ssrNav: ClipboardNavigator = {};
|
||||
const ssrWin: ClipboardWindowLike = { isSecureContext: true };
|
||||
|
||||
function describe(result: ClipboardResult<string>): string {
|
||||
return result.success
|
||||
? `[${result.method}] copied "${result.data}"`
|
||||
: `[${result.method}] error ${result.code}: ${result.error}`;
|
||||
}
|
||||
|
||||
const listener: PermissionChangeListener = (snapshot, name) => {
|
||||
console.info("change", name, snapshot);
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Every exported name is a TypeScript interface or type alias — see the
|
||||
listing in **Features**. Runtime exports: `DEFAULT_CLIPBOARD_OPTIONS`
|
||||
(frozen `ClipboardOptions` with `useFallback: true`,
|
||||
`requireSecureContext: false`, `serialize: true`, `focusRetry: true`,
|
||||
`sanitizer: undefined`, `pasteTimeoutMs: 1500`, `permissionTimeout: 5000`,
|
||||
`observability: undefined`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural-view interfaces (`ClipboardNavigator`,
|
||||
`ClipboardWindowLike`, `PermissionStatusLike`) are intentionally minimal
|
||||
— they expose only the fields the package actually reads. This keeps the
|
||||
DOM-typing surface narrow enough to substitute without depending on
|
||||
`@types/dom`, and lets the package run under SSR shims that don't carry
|
||||
a full DOM type pack. The `Readonly` modifiers across every option and
|
||||
result field communicate that the API never mutates caller-supplied data;
|
||||
the freeze on `DEFAULT_CLIPBOARD_OPTIONS` enforces that at runtime so
|
||||
shared references can't be accidentally mutated.
|
||||
149
packages/foundation/data/clipboard/src/types/clipboard.types.ts
Normal file
149
packages/foundation/data/clipboard/src/types/clipboard.types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { ClipboardObservability } from "../observability/observability.types.js";
|
||||
|
||||
/**
|
||||
* Permission state — mirrors the lowercase `PermissionState` reported by the
|
||||
* Permissions API. The package retains the `prompt` default for environments
|
||||
* without the API.
|
||||
*/
|
||||
export type ClipboardPermissionState = "granted" | "denied" | "prompt";
|
||||
|
||||
/**
|
||||
* MIME-keyed map for arbitrary binary payloads (anything other than
|
||||
* `text/plain`, `text/html`, and `image/*`).
|
||||
*/
|
||||
export type ClipboardBlobMap = Readonly<Record<string, Blob>>;
|
||||
|
||||
/**
|
||||
* Sanitizer hook applied to inbound `text/html` payloads.
|
||||
*/
|
||||
export type ClipboardSanitizer = (html: string) => string;
|
||||
|
||||
/**
|
||||
* Listener for permission state transitions. Receives the latest
|
||||
* {@link PermissionResult} snapshot and the descriptor name whose state
|
||||
* changed.
|
||||
*/
|
||||
export type PermissionChangeListener = (
|
||||
snapshot: PermissionResult,
|
||||
name: "clipboard-read" | "clipboard-write"
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Structured clipboard payload for rich content. Every field is optional —
|
||||
* callers populate the subset they have.
|
||||
*/
|
||||
export interface ClipboardData {
|
||||
/**Plain text payload (`text/plain`). */
|
||||
readonly text?: string;
|
||||
/** Raw HTML payload (`text/html`). Subject to the manager's sanitizer on read. */
|
||||
readonly html?: string;
|
||||
/**Binary image payload (any `image/*` MIME). */
|
||||
readonly image?: Blob;
|
||||
/** MIME-keyed map for arbitrary binary payloads. */
|
||||
readonly blobs?: ClipboardBlobMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options bag accepted by {@link ClipboardManager}. Every field has a default
|
||||
* from {@link DEFAULT_CLIPBOARD_OPTIONS} so callers only override what they
|
||||
* need.
|
||||
*/
|
||||
export interface ClipboardOptions {
|
||||
/**Use the `<textarea>` + `execCommand("copy")` fallback when the API is unavailable or rejects. */
|
||||
readonly useFallback: boolean;
|
||||
/** Probe `window.isSecureContext` and short-circuit copies in insecure contexts. */
|
||||
readonly requireSecureContext: boolean;
|
||||
/**Serialize copy/read calls through an internal AsyncQueue. */
|
||||
readonly serialize: boolean;
|
||||
/** Retry once after the next `focus` event when the API rejects with `NotAllowedError`. */
|
||||
readonly focusRetry: boolean;
|
||||
/**Optional HTML sanitizer applied to inbound rich payloads. */
|
||||
readonly sanitizer: ClipboardSanitizer | undefined;
|
||||
/** Paste-event fallback timeout in milliseconds. */
|
||||
readonly pasteTimeoutMs: number;
|
||||
/**Permission query timeout — preserved from upstream for API compatibility. */
|
||||
readonly permissionTimeout: number;
|
||||
/** Optional observability bundle to inject for metrics + traces. */
|
||||
readonly observability: ClipboardObservability | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of any clipboard operation. `code` carries a namespaced
|
||||
* `CLIPBOARD.*` error code on failure; consumers should match on `code`
|
||||
* rather than parsing `error` strings.
|
||||
*/
|
||||
export interface ClipboardResult<T = string> {
|
||||
readonly success: boolean;
|
||||
readonly data?: T;
|
||||
readonly error?: string;
|
||||
readonly code?: string;
|
||||
readonly method: "api" | "fallback";
|
||||
}
|
||||
|
||||
/** Result of {@link ClipboardManager.checkPermissions}. */
|
||||
export interface PermissionResult {
|
||||
readonly read: ClipboardPermissionState;
|
||||
readonly write: ClipboardPermissionState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural view of the Permissions API surface we use. Allows tests and
|
||||
* SSR shims to substitute a real-shaped object without depending on a global
|
||||
* `PermissionStatus` constructor.
|
||||
*/
|
||||
export interface PermissionStatusLike {
|
||||
readonly state: string;
|
||||
addEventListener?: (
|
||||
type: "change",
|
||||
cb: (this: PermissionStatusLike) => void
|
||||
) => void;
|
||||
removeEventListener?: (
|
||||
type: "change",
|
||||
cb: (this: PermissionStatusLike) => void
|
||||
) => void;
|
||||
onchange?: ((this: PermissionStatusLike) => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural view of `navigator.clipboard` + `navigator.permissions` used
|
||||
* by the manager. Tests inject this shape directly; production code uses
|
||||
* the default global `navigator`.
|
||||
*/
|
||||
export interface ClipboardNavigator {
|
||||
clipboard?: {
|
||||
writeText(text: string): Promise<void>;
|
||||
readText(): Promise<string>;
|
||||
write(items: ClipboardItem[]): Promise<void>;
|
||||
read(): Promise<ClipboardItem[]>;
|
||||
};
|
||||
permissions?: {
|
||||
query(descriptor: { name: string }): Promise<PermissionStatusLike>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Structural view of the host window used for secure-context detection. */
|
||||
export interface ClipboardWindowLike {
|
||||
readonly isSecureContext?: boolean;
|
||||
addEventListener?: (
|
||||
type: "focus",
|
||||
cb: () => void,
|
||||
opts?: { once?: boolean }
|
||||
) => void;
|
||||
removeEventListener?: (type: "focus", cb: () => void) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default {@link ClipboardOptions}. Extended fields default to enterprise-safe
|
||||
* values (serialization enabled, focus-retry enabled, paste timeout 1500ms,
|
||||
* no sanitizer, no required secure context, no observability).
|
||||
*/
|
||||
export const DEFAULT_CLIPBOARD_OPTIONS: ClipboardOptions = Object.freeze({
|
||||
useFallback: true,
|
||||
requireSecureContext: false,
|
||||
serialize: true,
|
||||
focusRetry: true,
|
||||
sanitizer: undefined,
|
||||
pasteTimeoutMs: 1500,
|
||||
permissionTimeout: 5000,
|
||||
observability: undefined,
|
||||
});
|
||||
12
packages/foundation/data/clipboard/tsconfig.lib.json
Normal file
12
packages/foundation/data/clipboard/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
52
packages/foundation/data/clipboard/vite.config.ts
Normal file
52
packages/foundation/data/clipboard/vite.config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/clipboard",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"manager/manager": "src/manager/manager.ts",
|
||||
"fallback/fallback": "src/fallback/fallback.ts",
|
||||
"permissions/permissions": "src/permissions/permissions.ts",
|
||||
"queue/queue": "src/queue/queue.ts",
|
||||
"observability/observability":
|
||||
"src/observability/observability.ts",
|
||||
"errors/errors": "src/errors/errors.ts",
|
||||
"security/security": "src/security/security.ts",
|
||||
"sanitizer/sanitizer": "src/sanitizer/sanitizer.ts",
|
||||
"types/clipboard.types": "src/types/clipboard.types.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/opentelemetry",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
7
packages/foundation/data/clipboard/vitest.config.ts
Normal file
7
packages/foundation/data/clipboard/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
environment: "happy-dom",
|
||||
fileParallelism: false,
|
||||
testTimeout: 5000,
|
||||
});
|
||||
Reference in New Issue
Block a user