7.2 KiB
Design — enterprise-clipboard
Non-normative rationale, algorithms, and file layout for the
@suaveplan/clipboardenterprise migration. Normative requirements live in./specs/clipboard/spec.md.
Layout
packages/foundation/data/clipboard/
README.md
package.json
vite.config.ts
vitest.config.ts
tsconfig.lib.json
src/
index.ts # explicit named re-exports
types/
clipboard.types.ts # shared types: ClipboardData/Result/Options/...
errors/
index.ts
errors.ts # CLIPBOARD_CODES + ClipboardError
errors.md
errors.test.ts
observability/
index.ts
observability.ts # createClipboardObservability
observability.md
observability.test.ts
observability.types.ts
security/
index.ts
security.ts # requireSecureContext + isSecure
security.md
security.test.ts
queue/
index.ts
queue.ts # AsyncQueue with FIFO ordering
queue.md
queue.test.ts
sanitizer/
index.ts
sanitizer.ts # default identity + Sanitizer interface
sanitizer.md
sanitizer.test.ts
permissions/
index.ts
permissions.ts # checkPermissions + onPermissionChange
permissions.md
permissions.test.ts
fallback/
index.ts
fallback.ts # textarea+execCommand + paste-event capture
fallback.md
fallback.test.ts
manager/
index.ts
manager.ts # ClipboardManager — orchestrates all features
manager.md
manager.test.ts
Public API
// CLIPBOARD_CODES
export const CLIPBOARD_CODES = defineCodes("CLIPBOARD", [
"API_UNAVAILABLE", // navigator.clipboard absent
"PERMISSION_DENIED", // PermissionState === "denied"
"FALLBACK_FAILED", // execCommand returned false or threw
"INSECURE_CONTEXT", // window.isSecureContext === false
"NO_CONTENT", // copyRichContent called with empty data
"READ_FAILED", // read* threw
"DOCUMENT_UNAVAILABLE", // document undefined (SSR)
"QUEUE_DRAINED", // queued op cancelled because Manager was disposed
] as const);
// Errors
export class ClipboardError extends SuaveplanError<ClipboardErrorCode> {}
// Manager
export class ClipboardManager {
constructor(opts?: Partial<ClipboardOptions>, nav?: ClipboardNavigator);
copyText(text: string): Promise<ClipboardResult<string>>;
copyRichContent(data: ClipboardData): Promise<ClipboardResult<ClipboardData>>;
readText(): Promise<ClipboardResult<string>>;
readRichContent(): Promise<ClipboardResult<ClipboardData>>;
checkPermissions(): Promise<PermissionResult>;
onPermissionChange(cb: PermissionChangeListener): () => void;
isSupported(): boolean;
isSecure(): boolean;
dispose(): void;
}
Async queue
AsyncQueue holds a tail Promise<void> and every enqueue(fn) call chains:
class AsyncQueue {
private tail: Promise<unknown> = Promise.resolve();
enqueue<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(() => fn(), () => fn());
this.tail = result.catch(() => undefined);
return result;
}
drain(): Promise<void> { return this.tail.then(() => undefined, () => undefined); }
}
When serialize: false is passed, the manager calls fn() directly.
Fallback flow
copyTextFallback(text) creates a hidden <textarea>, focuses it, runs
document.execCommand("copy"), and removes the element in a finally block. The
focusRetry option causes the manager to await one focus event before retrying
once if the first API call rejects with a NotAllowedError.
readTextFallback() (paste-event fallback) creates a focusable hidden textarea,
listens for paste, dispatches focus, and races Promise<{ value | reject }> with
a 1500ms timeout. The textarea is removed on success and on timeout.
Observability
Five instruments on the @suaveplan/clipboard meter:
| Instrument | Type | Attributes |
|---|---|---|
clipboard_copy_total |
counter | `method=api |
clipboard_read_total |
counter | `method=api |
clipboard_fallback_used_total |
counter | `reason=no_api |
clipboard_error_total |
counter | code=<CLIPBOARD.*> |
clipboard_op_latency_ms |
histogram | `op=copyText |
The histogram is recorded around every public method via recordOp(observability, op, method, startMs).
Error code → HTTP / retry table
| Code | httpStatus | retryable | Description |
|---|---|---|---|
API_UNAVAILABLE |
501 | false | navigator.clipboard undefined |
PERMISSION_DENIED |
403 | false | Permissions API returned "denied" |
FALLBACK_FAILED |
500 | false | execCommand returned false / threw |
INSECURE_CONTEXT |
400 | false | window.isSecureContext === false |
NO_CONTENT |
400 | false | copyRichContent called with empty ClipboardData |
READ_FAILED |
500 | true | API read promise rejected |
DOCUMENT_UNAVAILABLE |
501 | false | SSR / Node — no document |
QUEUE_DRAINED |
503 | true | manager disposed mid-flight |
Test strategy
happy-dom (default Vitest environment) supplies document, navigator,
window.isSecureContext, ClipboardEvent, Blob. navigator.clipboard is provided
as a real shape on a constructor-injected ClipboardNavigator parameter — the
default constructor falls back to navigator which under happy-dom is the real
object. We never vi.mock(...) runtime modules; instead the ClipboardNavigator
shape is the seam.
Coverage targets every branch:
copyTexthappy path, API rejection, API rejection withuseFallback: false, document absent,execCommand → false,execCommand throws, focus retry success, focus retry exhaustion, queue serialization.copyRichContenthappy paths for text-only, html-only, text+html, image-only, blobs map, mixed, empty (NO_CONTENT), API failure with fallback, API failure without fallback, missingClipboardItem.readTexthappy path, API rejection, paste-event fallback success, paste-event fallback timeout, document absent.readRichContenthappy path with text+html, image item, arbitrary MIME blob, empty items, API rejection, sanitizer applied to html.checkPermissionsgranted/denied/prompt, no permissions API, query throws.onPermissionChangesubscribe + receive + unsubscribe + manager dispose.requireSecureContexttrue and false.
Why foundation/data
packages/foundation/data/ is the canonical home for browser-tier data-transfer
primitives (packer-browser, serialize, mime, templates, bitpacker).
Clipboard is the user-driven data-transfer surface that complements the
programmatic ones in this folder. Adding it to data/ keeps the plugin set
defined in scripts/folder-reorg/assignment.yml stable and avoids inventing a
new plugin solely for one file-VFS-shaped package.