185 lines
7.2 KiB
Markdown
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.
|