Files
suaveplan/openspec/archive/2026-05-25-enterprise-clipboard/design.md
2026-05-27 23:29:53 +01:00

185 lines
7.2 KiB
Markdown

# Design — enterprise-clipboard
> Non-normative rationale, algorithms, and file layout for the
> `@suaveplan/clipboard` enterprise migration. Normative requirements live in
> [`./specs/clipboard/spec.md`](./specs/clipboard/spec.md).
## Layout
```text
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
```ts
// 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:
```ts
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|fallback`, `kind=text|rich` |
| `clipboard_read_total` | counter | `method=api|fallback`, `kind=text|rich` |
| `clipboard_fallback_used_total` | counter | `reason=no_api|api_threw|insecure_context` |
| `clipboard_error_total` | counter | `code=<CLIPBOARD.*>` |
| `clipboard_op_latency_ms` | histogram | `op=copyText|readText|copyRich|readRich`, `method=api|fallback` |
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.