Initial Commit
This commit is contained in:
184
openspec/archive/2026-05-25-enterprise-clipboard/design.md
Normal file
184
openspec/archive/2026-05-25-enterprise-clipboard/design.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user