Files
2026-05-27 23:29:53 +01:00

7.2 KiB

Design — enterprise-clipboard

Non-normative rationale, algorithms, and file layout for the @suaveplan/clipboard enterprise 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:

  • copyText happy path, API rejection, API rejection with useFallback: false, document absent, execCommand → false, execCommand throws, focus retry success, focus retry exhaustion, queue serialization.
  • copyRichContent happy paths for text-only, html-only, text+html, image-only, blobs map, mixed, empty (NO_CONTENT), API failure with fallback, API failure without fallback, missing ClipboardItem.
  • readText happy path, API rejection, paste-event fallback success, paste-event fallback timeout, document absent.
  • readRichContent happy path with text+html, image item, arbitrary MIME blob, empty items, API rejection, sanitizer applied to html.
  • checkPermissions granted/denied/prompt, no permissions API, query throws.
  • onPermissionChange subscribe + receive + unsubscribe + manager dispose.
  • requireSecureContext true 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.