@anvilkit/plugin-asset-manager 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +366 -108
  2. package/dist/adapters/data-url.cjs +6 -2
  3. package/dist/adapters/data-url.d.cts.map +1 -1
  4. package/dist/adapters/data-url.d.ts.map +1 -1
  5. package/dist/adapters/data-url.js +6 -2
  6. package/dist/adapters/extract-image-dimensions.cjs +9 -0
  7. package/dist/adapters/extract-image-dimensions.d.cts +6 -0
  8. package/dist/adapters/extract-image-dimensions.d.cts.map +1 -1
  9. package/dist/adapters/extract-image-dimensions.d.ts +6 -0
  10. package/dist/adapters/extract-image-dimensions.d.ts.map +1 -1
  11. package/dist/adapters/extract-image-dimensions.js +9 -0
  12. package/dist/adapters/in-memory.cjs +6 -4
  13. package/dist/adapters/in-memory.d.cts +6 -0
  14. package/dist/adapters/in-memory.d.cts.map +1 -1
  15. package/dist/adapters/in-memory.d.ts +6 -0
  16. package/dist/adapters/in-memory.d.ts.map +1 -1
  17. package/dist/adapters/in-memory.js +6 -4
  18. package/dist/adapters/s3-presigned.cjs +78 -28
  19. package/dist/adapters/s3-presigned.d.cts +1 -0
  20. package/dist/adapters/s3-presigned.d.cts.map +1 -1
  21. package/dist/adapters/s3-presigned.d.ts +1 -0
  22. package/dist/adapters/s3-presigned.d.ts.map +1 -1
  23. package/dist/adapters/s3-presigned.js +78 -28
  24. package/dist/csp.d.cts.map +1 -1
  25. package/dist/csp.d.ts.map +1 -1
  26. package/dist/errors.d.cts.map +1 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/index.d.cts +3 -3
  29. package/dist/index.d.cts.map +1 -1
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/plugin.cjs +32 -5
  33. package/dist/plugin.d.cts +4 -3
  34. package/dist/plugin.d.cts.map +1 -1
  35. package/dist/plugin.d.ts +4 -3
  36. package/dist/plugin.d.ts.map +1 -1
  37. package/dist/plugin.js +22 -5
  38. package/dist/registry.d.cts.map +1 -1
  39. package/dist/registry.d.ts.map +1 -1
  40. package/dist/resolver.cjs +6 -1
  41. package/dist/resolver.d.cts.map +1 -1
  42. package/dist/resolver.d.ts.map +1 -1
  43. package/dist/resolver.js +6 -1
  44. package/dist/retry.cjs +10 -8
  45. package/dist/retry.d.cts.map +1 -1
  46. package/dist/retry.d.ts.map +1 -1
  47. package/dist/retry.js +10 -8
  48. package/dist/studio-asset-source.cjs +26 -5
  49. package/dist/studio-asset-source.d.cts +2 -2
  50. package/dist/studio-asset-source.d.cts.map +1 -1
  51. package/dist/studio-asset-source.d.ts +2 -2
  52. package/dist/studio-asset-source.d.ts.map +1 -1
  53. package/dist/studio-asset-source.js +26 -5
  54. package/dist/testing/index.d.cts.map +1 -1
  55. package/dist/testing/index.d.ts.map +1 -1
  56. package/dist/types.d.cts +9 -1
  57. package/dist/types.d.cts.map +1 -1
  58. package/dist/types.d.ts +9 -1
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/ui/AssetBrowser.cjs +54 -19
  61. package/dist/ui/AssetBrowser.d.cts +10 -1
  62. package/dist/ui/AssetBrowser.d.cts.map +1 -1
  63. package/dist/ui/AssetBrowser.d.ts +10 -1
  64. package/dist/ui/AssetBrowser.d.ts.map +1 -1
  65. package/dist/ui/AssetBrowser.js +54 -19
  66. package/dist/ui/AssetCommandPalette.cjs +18 -9
  67. package/dist/ui/AssetCommandPalette.d.cts.map +1 -1
  68. package/dist/ui/AssetCommandPalette.d.ts.map +1 -1
  69. package/dist/ui/AssetCommandPalette.js +18 -9
  70. package/dist/ui/AssetManagerUI.cjs +1 -0
  71. package/dist/ui/AssetManagerUI.d.cts.map +1 -1
  72. package/dist/ui/AssetManagerUI.d.ts.map +1 -1
  73. package/dist/ui/AssetManagerUI.js +1 -0
  74. package/dist/ui/DeleteAssetDialog.cjs +1 -0
  75. package/dist/ui/DeleteAssetDialog.d.cts.map +1 -1
  76. package/dist/ui/DeleteAssetDialog.d.ts.map +1 -1
  77. package/dist/ui/DeleteAssetDialog.js +1 -0
  78. package/dist/ui/MetadataPanel.cjs +1 -0
  79. package/dist/ui/MetadataPanel.d.cts.map +1 -1
  80. package/dist/ui/MetadataPanel.d.ts.map +1 -1
  81. package/dist/ui/MetadataPanel.js +1 -0
  82. package/dist/ui/ReplaceAssetDialog.cjs +1 -0
  83. package/dist/ui/ReplaceAssetDialog.d.cts.map +1 -1
  84. package/dist/ui/ReplaceAssetDialog.d.ts.map +1 -1
  85. package/dist/ui/ReplaceAssetDialog.js +1 -0
  86. package/dist/ui/UploadButton.cjs +22 -7
  87. package/dist/ui/UploadButton.d.cts.map +1 -1
  88. package/dist/ui/UploadButton.d.ts.map +1 -1
  89. package/dist/ui/UploadButton.js +22 -7
  90. package/dist/ui/index.d.cts.map +1 -1
  91. package/dist/ui/index.d.ts.map +1 -1
  92. package/dist/validate-upload-result.cjs +49 -10
  93. package/dist/validate-upload-result.d.cts.map +1 -1
  94. package/dist/validate-upload-result.d.ts.map +1 -1
  95. package/dist/validate-upload-result.js +49 -10
  96. package/package.json +8 -8
package/README.md CHANGED
@@ -1,159 +1,417 @@
1
- # `@anvilkit/plugin-asset-manager`
1
+ # @anvilkit/plugin-asset-manager
2
2
 
3
- Headless asset upload plugin for Anvilkit Studio.
3
+ > **Alpha (`0.1.3`).** Public surface may still shift before `v1.0`. Bundle budgets enforced in CI: headless ≤ 6 KB gzip, UI subpath ≤ 12 KB gzip.
4
4
 
5
- ## What it ships
5
+ Headless asset manager plugin for Anvilkit Studio. The host provides the upload backend; the plugin handles validation, registration, search, IR-time resolution, CSP guidance, and (optionally) a React UI for the upload + browse experience. Designed for pluggable production backends (S3, GCS, custom HTTP) with strict trust-boundary enforcement on every adapter response.
6
6
 
7
- - `createAssetManagerPlugin(options)` for Studio registration and runtime upload handling.
8
- - `uploadAsset(ctx, file)` for invoking the configured upload adapter and registering the validated result.
9
- - `getAssetRegistry(ctx)` for reading the plugin runtime registry after `onInit`.
10
- - `createAssetReference(id)` for producing stable `asset://<id>` references.
11
- - `createAssetRegistry()` for storing validated asset metadata, with `search`, `setTags`, and pagination.
12
- - `getRequiredCsp(opts)` for computing the minimum `connect-src` / `img-src` / `media-src` directives the configured adapters need.
13
- - `./ui` React components for a host-rendered upload button, asset browser (with search + filter chips), command palette, and metadata editor.
14
- - Reference upload adapters for tests and demos.
7
+ ## Installation
15
8
 
16
- ## Library management (search, tags, pagination)
9
+ ```bash
10
+ pnpm add @anvilkit/plugin-asset-manager @anvilkit/core react react-dom @puckeditor/core
11
+ ```
12
+
13
+ Non-optional peers: `react ^18.2.0 || ^19.0.0`, `react-dom ^18.2.0 || ^19.0.0`, `@puckeditor/core ^0.21.2`.
14
+
15
+ Subpath imports:
17
16
 
18
- `AssetRegistry.search(options)` returns a paginated, filtered slice of the registry:
17
+ - `@anvilkit/plugin-asset-manager` plugin factory, validation, adapters, CSP advisor, errors.
18
+ - `@anvilkit/plugin-asset-manager/ui` — React UI components.
19
+ - `@anvilkit/plugin-asset-manager/retry` — generic `RetryableError` + `withRetry()`.
20
+ - `@anvilkit/plugin-asset-manager/adapters/s3` — production `s3PresignedAdapter`.
21
+ - `@anvilkit/plugin-asset-manager/testing` — fixtures for downstream plugin tests.
22
+
23
+ ## Quickstart
19
24
 
20
25
  ```ts
21
- const page = registry.search({
22
- query: "hero", // matches id, name, MIME prefix, and tags (case-insensitive)
23
- kinds: ["image"], // filter by inferred kind (image/video/audio/font/document/other)
24
- tags: ["brand"], // require all listed tags (AND semantics)
25
- limit: 20,
26
- cursor: undefined, // opaque; pass `page.nextCursor` to advance
26
+ import {
27
+ createAssetManagerPlugin,
28
+ dataUrlUploader,
29
+ } from "@anvilkit/plugin-asset-manager";
30
+ import { Studio } from "@anvilkit/core";
31
+
32
+ const assetManager = createAssetManagerPlugin({
33
+ uploader: dataUrlUploader(),
27
34
  });
35
+
36
+ <Studio puckConfig={puckConfig} plugins={[assetManager]} />;
28
37
  ```
29
38
 
30
- Tags are auto-derived on every upload (kind + up to two filename tokens, capped at 3) and can be edited via `registry.setTags(id, tags)` or the `MetadataPanel` UI. Host-supplied `UploadResult.tags` are preserved verbatim.
39
+ `dataUrlUploader` is dev-only files are converted to in-memory `data:` URLs (1 MB cap by default). For production, swap in `s3PresignedAdapter` or a custom `UploadAdapter`.
31
40
 
32
- `StudioAssetSource.listPaginated(query)` is the sidebar-facing pagination contract — remote sources can implement it to push search and pagination to the server. Sidebar consumers fall back to `list()` when omitted.
41
+ ## Core features
33
42
 
34
- `StudioAssetSource.subscribeUploads(listener)` is a streaming channel that fans `progress` / `done` / `error` envelopes out to every subscriber alongside the inline upload listener.
43
+ - **Pluggable upload adapters** `dataUrlUploader`, `inMemoryUploader`, and `s3PresignedAdapter` ship in-box; custom adapters implement the `UploadAdapter` function signature.
44
+ - **Strict trust model** — every adapter response is validated through `validateUploadResult`: scheme allowlist, path-traversal guard, IDN homoglyph guard. `javascript:` / `vbscript:` are hard-blocked. `data:` is opt-in.
45
+ - **In-memory asset registry** — search (`query` / `kinds` / `tags`), opaque cursor pagination, auto-derived tags, rename / retag / replace / delete.
46
+ - **IR-time resolution** — `createIRAssetResolver` + `resolveAssets` turn `asset://<id>` references into validated URLs at export / render time.
47
+ - **CSP advisor** — `getRequiredCsp` computes the minimum `connect-src` / `img-src` / `media-src` directives the configured adapters need.
48
+ - **Production-ready S3 adapter** — `s3PresignedAdapter` POST-then-PUT with exponential-backoff retry on 5xx + network failures (4xx fails fast).
49
+ - **Optional React UI** — `UploadButton`, `AssetBrowser`, `AssetCommandPalette`, `MetadataPanel`, `ReplaceAssetDialog`, `DeleteAssetDialog`, and the composite `AssetManagerUI`.
50
+ - **Batch upload control** — `StudioAssetSource.upload(files)` honors `maxConcurrentUploads` (default 3) and `AbortSignal`.
35
51
 
36
- ## Trust model
52
+ ## API reference
37
53
 
38
- `UploadAdapter.url` is treated as untrusted input. Every adapter response is validated through `validateUploadResult()` before it reaches the registry or IR-shaped data.
54
+ ### Plugin factory
39
55
 
40
- The default scheme set is **always** `http`, `https`, and `blob`. `javascript:` and `vbscript:` are hard-blocked under all configurations. `data:` is **off by default** in v1.0 — enable it explicitly per plugin instance:
56
+ ```ts
57
+ function createAssetManagerPlugin(options: AssetManagerOptions): StudioPlugin;
58
+ ```
59
+
60
+ | Field | Type | Default | Purpose |
61
+ | --------------------------- | ---------------------------------------------- | ---------- | ------------------------------------------------------------------------- |
62
+ | `uploader` | `UploadAdapter` | _required_ | Upload backend. |
63
+ | `maxFileSize` | `number` | none | Bytes. Enforced before the adapter runs. |
64
+ | `acceptedMimeTypes` | `readonly string[]` | none | Allowlist. Enforced before the adapter runs. |
65
+ | `dataUrlAllowlistOptIn` | `boolean` | `false` | When `true`, `data:` URLs are valid output. |
66
+ | `allowMixedScriptHostnames` | `boolean` | `false` | When `true`, hostnames mixing Latin with a confusable script are allowed. |
67
+ | `getThumbnail` | `(entry: UploadResult) => string \| undefined` | none | Optional override for the displayed thumbnail. |
68
+
69
+ ### Imperative API on the plugin context
70
+
71
+ | Function | Signature | Purpose |
72
+ | ---------------------- | ----------------------------------------------- | ---------------------------------------------------------- |
73
+ | `uploadAsset` | `(ctx, file, signal?) => Promise<UploadResult>` | Validate file → run uploader → validate result → register. |
74
+ | `getAssetRegistry` | `(ctx) => AssetRegistry \| undefined` | Read the runtime registry after `onInit`. |
75
+ | `createAssetReference` | `(id) => string` | Produce a stable `asset://<id>` reference for IR. |
76
+
77
+ ### `UploadAdapter`
41
78
 
42
79
  ```ts
43
- createAssetManagerPlugin({
44
- uploader: dataUrlUploader(),
45
- dataUrlAllowlistOptIn: true,
46
- });
80
+ type UploadAdapter = (
81
+ file: File,
82
+ options?: UploadAdapterOptions, // { signal?: AbortSignal }
83
+ ) => Promise<UploadResult>;
84
+
85
+ interface UploadResult {
86
+ readonly url: string;
87
+ readonly id: string;
88
+ readonly name?: string;
89
+ readonly meta?: AssetMeta; // { size?, mimeType?, width?, height? }
90
+ readonly tags?: readonly string[];
91
+ }
47
92
  ```
48
93
 
49
- Two further hardening checks run at validation time:
94
+ ### Reference adapters
95
+
96
+ | Adapter | Use case | Notes |
97
+ | -------------------------- | ---------- | ---------------------------------------------------------------------------------------------------- |
98
+ | `dataUrlUploader(opts?)` | Dev, demos | `maxBytes` default 1 MB. Extracts image dimensions. |
99
+ | `inMemoryUploader()` | Tests | Stores files in memory with `blob:` URLs. |
100
+ | `s3PresignedAdapter(opts)` | Production | POST `{ name, type, size }` to `presignEndpoint`; PUT file to returned `url`. Retries 5xx + network. |
50
101
 
51
- - **Path traversal.** `../` and percent-encoded variants (`%2e%2e/`, `%2e%2e%2f`) are rejected on `http`/`https`/`blob` URLs.
52
- - **IDN homoglyph.** Hostnames mixing Latin with a visually confusable script (Cyrillic or Greek) are rejected. Single-script IDN hosts such as `münchen.de`, `日本.jp`, or `россия.рф` are always allowed. Hosts that legitimately need this combination can pass `allowMixedScriptHostnames: true`.
102
+ `s3PresignedAdapter` options:
53
103
 
54
- ### Migration from `urlAllowlist` (alpha → 1.0)
104
+ | Field | Default | Purpose |
105
+ | ----------------- | --------------------- | -------------------------------------------------------------- |
106
+ | `presignEndpoint` | _required_ | URL that returns `{ url, publicUrl?, headers?, id? }`. |
107
+ | `fetch` | `globalThis.fetch` | Injectable fetch implementation (for tests / instrumentation). |
108
+ | `region` | none | Recorded for logs only; not validated. |
109
+ | `retry` | `{ maxRetries: 3 }` | Forwarded to `withRetry()` for both phases. |
110
+ | `signal` | none | Aborts in-flight presign + PUT + any retry sleep. |
111
+ | `headers` | none | Extra headers on the presign POST (e.g., auth). |
112
+ | `idGenerator` | `crypto.randomUUID()` | Asset id override. |
55
113
 
56
- The alpha-era `urlAllowlist?: readonly string[]` field is removed in v1.0. Replace any existing usage with the typed flags:
114
+ ### `AssetRegistry`
57
115
 
58
- | Alpha | v1.0 |
59
- | ---------------------------------------------- | ----------------------------------- |
60
- | `urlAllowlist: ["http", "https", "blob"]` | _(default — drop the field)_ |
61
- | `urlAllowlist: ["http", "https", "blob", "data"]` | `dataUrlAllowlistOptIn: true` |
62
- | `urlAllowlist: ["http", "https", "blob", "ftp"]` | _not supported — write a custom resolver wrapper_ |
116
+ ```ts
117
+ interface AssetRegistry {
118
+ register(asset: UploadResult): UploadResult;
119
+ get(id: string): UploadResult | undefined;
120
+ list(): readonly UploadResult[];
121
+ delete(id: string): boolean;
122
+ rename(id: string, name: string): UploadResult | undefined;
123
+ replace(id: string, next: UploadResult): UploadResult | undefined;
124
+ setTags(id: string, tags: readonly string[]): UploadResult | undefined;
125
+ search(options?: AssetSearchOptions): AssetSearchPage;
126
+ subscribe(listener: AssetRegistryListener): () => void;
127
+ }
128
+
129
+ interface AssetSearchOptions {
130
+ readonly query?: string; // matches id, name, MIME prefix, tags (case-insensitive)
131
+ readonly kinds?: readonly AssetKind[];
132
+ readonly tags?: readonly string[]; // AND semantics
133
+ readonly cursor?: string;
134
+ readonly limit?: number;
135
+ }
136
+
137
+ interface AssetSearchPage {
138
+ readonly items: readonly UploadResult[];
139
+ readonly total: number;
140
+ readonly nextCursor: string | undefined;
141
+ }
142
+ ```
143
+
144
+ `AssetKind` is one of `"image" | "video" | "audio" | "font" | "document" | "other"` — inferred from MIME via `inferAssetKind(mimeType)`.
63
145
 
64
- If your host needs an exotic scheme (`s3:`, `ipfs:`, `gs:`), wrap `validateUploadResult` and pass the result to `registry.register` directly. The rationale for dropping arbitrary scheme strings: every scheme allowed at the boundary needs its own CSP / sanitization story — typed flags force that decision to be explicit.
146
+ ### Sidebar source bridge
147
+
148
+ ```ts
149
+ function createStudioAssetSource(
150
+ options: CreateStudioAssetSourceOptions,
151
+ ): StudioAssetSource;
152
+
153
+ interface CreateStudioAssetSourceOptions {
154
+ readonly registry: AssetRegistry;
155
+ readonly upload: (
156
+ file: File,
157
+ options?: UploadAdapterOptions,
158
+ ) => Promise<UploadResult>;
159
+ readonly getThumbnail?: (entry: UploadResult) => string | undefined;
160
+ readonly maxConcurrentUploads?: number; // default 3
161
+ }
162
+ ```
65
163
 
66
- ## Content Security Policy
164
+ Sidebar consumers can also call `inferStudioAssetKind(entry)` directly.
67
165
 
68
- `getRequiredCsp(options)` computes the minimum CSP directives the configured adapters need. Call it once at boot and merge the result into your existing CSP builder:
166
+ ### IR resolution
167
+
168
+ | Export | Signature | Purpose |
169
+ | ----------------------- | --------------------------------------------------------- | -------------------------------------------------------- |
170
+ | `createIRAssetResolver` | `(opts: CreateIRAssetResolverOptions) => IRAssetResolver` | Resolves `asset://<id>` references against the registry. |
171
+ | `resolveAssets` | `(ir: PageIR, resolver) => PageIR` | Walks the IR tree and rewrites asset references. |
69
172
 
70
173
  ```ts
71
- import { getRequiredCsp } from "@anvilkit/plugin-asset-manager";
174
+ interface CreateIRAssetResolverOptions {
175
+ readonly registry: AssetRegistry;
176
+ readonly dataUrlAllowlistOptIn?: boolean;
177
+ readonly allowMixedScriptHostnames?: boolean;
178
+ }
179
+ ```
72
180
 
73
- const csp = getRequiredCsp({
74
- dataUrl: true,
75
- s3: { presignEndpoint: "https://uploads.example.com/sign" },
76
- });
77
- // csp.connectSrc → ["https://uploads.example.com"]
78
- // csp.imgSrc → ["data:", "https://uploads.example.com"]
79
- // csp.mediaSrc → ["data:", "https://uploads.example.com"]
181
+ ### Validation & security
182
+
183
+ ```ts
184
+ function validateUploadResult(
185
+ result: UploadResult,
186
+ options?: ValidateUploadResultOptions,
187
+ ): UploadResult;
80
188
  ```
81
189
 
82
- | Adapter | `connect-src` | `img-src` | `media-src` |
83
- | ---------------------- | ---------------------------- | ---------------------------- | ---------------------------- |
84
- | `dataUrlUploader` | _(none)_ | `data:` | `data:` |
85
- | `inMemoryUploader` | _(none)_ | `blob:` | `blob:` |
86
- | `s3PresignedAdapter` | presign origin + `publicHost`| `publicHost` ?? presign | `publicHost` ?? presign |
190
+ Throws `AssetValidationError` on bad input. Always enforces:
87
191
 
88
- If your `publicUrl` returned by the presign service points at a different origin than the presign endpoint itself, pass `s3: { presignEndpoint, publicHost }` so the advisor can split the directives correctly.
192
+ - Default scheme set: `http`, `https`, `blob`.
193
+ - Hard-blocks: `javascript:`, `vbscript:`.
194
+ - Path traversal: `../` and percent-encoded variants (`%2e%2e/`, `%2e%2e%2f`) rejected on `http`/`https`/`blob` URLs.
195
+ - IDN homoglyph: hostnames mixing Latin with Cyrillic or Greek are rejected unless `allowMixedScriptHostnames: true`. Single-script IDN hosts (`münchen.de`, `日本.jp`, `россия.рф`) are always allowed.
89
196
 
90
- ## Entry points
197
+ ### CSP advisor
91
198
 
92
- - `@anvilkit/plugin-asset-manager` — headless runtime, validation, adapters, errors, CSP advisor.
93
- - `@anvilkit/plugin-asset-manager/ui` React UI components.
94
- - `@anvilkit/plugin-asset-manager/retry` — generic `RetryableError` + `withRetry()`.
95
- - `@anvilkit/plugin-asset-manager/adapters/s3` — `s3PresignedAdapter`.
96
- - `@anvilkit/plugin-asset-manager/testing` — fixtures for downstream plugin tests.
199
+ ```ts
200
+ function getRequiredCsp(options: RequiredCspOptions): RequiredCsp;
201
+ ```
202
+
203
+ | Adapter | `connect-src` | `img-src` | `media-src` |
204
+ | -------------------- | ----------------------------- | ----------------------- | ----------------------- |
205
+ | `dataUrlUploader` | _(none)_ | `data:` | `data:` |
206
+ | `inMemoryUploader` | _(none)_ | `blob:` | `blob:` |
207
+ | `s3PresignedAdapter` | presign origin + `publicHost` | `publicHost` ?? presign | `publicHost` ?? presign |
97
208
 
98
- ## Batch upload behaviour
209
+ ### React UI (`./ui`)
99
210
 
100
- `StudioAssetSource.upload(files)` runs up to `MAX_CONCURRENT_UPLOADS` (default 3) uploads in parallel. Per-file failures surface via the listener as `error` envelopes; the returned promise resolves with the successful subset and **does not throw**. Hosts that need fail-fast semantics can pass `maxConcurrentUploads: 1`.
211
+ | Component | Key props |
212
+ | --------------------- | --------------------------------------------- |
213
+ | `UploadButton` | `{ onUpload, onProgress?, disabled? }` |
214
+ | `AssetBrowser` | `{ registry, onSelect, maxWidth? }` |
215
+ | `AssetCommandPalette` | `{ registry, onSelect }` |
216
+ | `MetadataPanel` | `{ asset, registry, onClose }` |
217
+ | `ReplaceAssetDialog` | `{ asset, onReplace, onCancel }` |
218
+ | `DeleteAssetDialog` | `{ asset, onDelete, onCancel }` |
219
+ | `AssetManagerUI` | `{ registry, plugin, maxWidth? }` (composite) |
101
220
 
102
- `AbortError`s thrown from the adapter abort the whole batch — pending files are not scheduled.
221
+ `UploadProgressSnapshot` is `{ inFlight: number; completed: number }`.
103
222
 
104
- ## S3 presigned adapter
223
+ ### Retry helpers (`./retry`)
105
224
 
106
225
  ```ts
107
- import { s3PresignedAdapter } from "@anvilkit/plugin-asset-manager/adapters/s3";
108
- import { createAssetManagerPlugin } from "@anvilkit/plugin-asset-manager";
226
+ class RetryableError extends Error {
227
+ readonly retryAfterMs?: number;
228
+ }
229
+
230
+ interface RetryOptions {
231
+ readonly maxRetries?: number; // default 3
232
+ readonly baseDelayMs?: number; // default 250
233
+ readonly maxDelayMs?: number; // default 8000
234
+ readonly signal?: AbortSignal;
235
+ readonly jitter?: () => number; // default Math.random
236
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
237
+ }
238
+
239
+ function withRetry<T>(
240
+ fn: (attempt: number) => Promise<T>,
241
+ options?: RetryOptions,
242
+ ): Promise<T>;
243
+ ```
244
+
245
+ Full-jitter exponential backoff; the optional `retryAfterMs` on a `RetryableError` overrides the computed delay (use this when the server returned a `Retry-After` header).
109
246
 
247
+ ### Errors
248
+
249
+ `AssetValidationError.code`:
250
+
251
+ | Code | Source |
252
+ | ------------------------------ | --------------------------- |
253
+ | `FILE_TOO_LARGE` | pre-upload file validation |
254
+ | `UNSUPPORTED_MIME_TYPE` | pre-upload file validation |
255
+ | `INVALID_UPLOAD_ID` | `validateUploadResult` |
256
+ | `EMPTY_UPLOAD_URL` | `validateUploadResult` |
257
+ | `UNSCHEMED_UPLOAD_URL` | `validateUploadResult` |
258
+ | `DISALLOWED_UPLOAD_URL_SCHEME` | `validateUploadResult` |
259
+ | `PATH_TRAVERSAL_URL` | `validateUploadResult` |
260
+ | `MIXED_SCRIPT_HOSTNAME` | `validateUploadResult` |
261
+ | `DATA_URL_FILE_TOO_LARGE` | `dataUrlUploader` |
262
+ | `DATA_URL_READ_FAILED` | `dataUrlUploader` |
263
+ | `UPLOAD_FAILED` | `uploadAsset` fallback wrap |
264
+
265
+ `AssetResolutionError.code`:
266
+
267
+ | Code | Meaning |
268
+ | ------------------------- | -------------------------------------------------- |
269
+ | `ASSET_NOT_FOUND` | The registry has no entry for the asset id. |
270
+ | `ASSET_URL_REJECTED` | The stored URL failed the allowlist or trust gate. |
271
+ | `ASSET_VALIDATION_FAILED` | Catch-all for unexpected resolver failures. |
272
+
273
+ ## Usage examples
274
+
275
+ ### Data-URL adapter for local development
276
+
277
+ ```ts
110
278
  createAssetManagerPlugin({
111
- uploader: s3PresignedAdapter({
112
- presignEndpoint: "/api/sign",
113
- retry: { maxRetries: 3 },
114
- }),
279
+ uploader: dataUrlUploader({ maxBytes: 2_000_000 }),
280
+ dataUrlAllowlistOptIn: true,
281
+ });
282
+ ```
283
+
284
+ ### Production S3 wiring
285
+
286
+ ```ts
287
+ import {
288
+ createAssetManagerPlugin,
289
+ getRequiredCsp,
290
+ } from "@anvilkit/plugin-asset-manager";
291
+ import { s3PresignedAdapter } from "@anvilkit/plugin-asset-manager/adapters/s3";
292
+
293
+ const uploader = s3PresignedAdapter({
294
+ presignEndpoint: "/api/assets/sign",
295
+ headers: { Authorization: `Bearer ${apiKey}` },
296
+ retry: { maxRetries: 3 },
297
+ });
298
+
299
+ const plugin = createAssetManagerPlugin({ uploader });
300
+
301
+ const csp = getRequiredCsp({
302
+ s3: {
303
+ presignEndpoint: "/api/assets/sign",
304
+ publicHost: "https://cdn.example.com",
305
+ },
306
+ });
307
+
308
+ response.setHeader(
309
+ "Content-Security-Policy",
310
+ [
311
+ `default-src 'self'`,
312
+ `connect-src 'self' ${csp.connectSrc.join(" ")}`,
313
+ `img-src 'self' ${csp.imgSrc.join(" ")}`,
314
+ `media-src 'self' ${csp.mediaSrc.join(" ")}`,
315
+ ].join("; "),
316
+ );
317
+ ```
318
+
319
+ ### Custom upload adapter
320
+
321
+ ```ts
322
+ import type { UploadAdapter } from "@anvilkit/plugin-asset-manager";
323
+ import {
324
+ RetryableError,
325
+ withRetry,
326
+ } from "@anvilkit/plugin-asset-manager/retry";
327
+
328
+ const myCdnUploader: UploadAdapter = async (file, { signal } = {}) =>
329
+ withRetry(
330
+ async () => {
331
+ const response = await fetch("/api/cdn", {
332
+ method: "POST",
333
+ body: file,
334
+ headers: { "Content-Type": file.type, "X-Filename": file.name },
335
+ signal,
336
+ });
337
+ if (response.status >= 500) {
338
+ throw new RetryableError(`CDN ${response.status}`);
339
+ }
340
+ if (!response.ok) {
341
+ throw new Error(`CDN ${response.status}: ${await response.text()}`);
342
+ }
343
+ const { url, id } = await response.json();
344
+ return {
345
+ id,
346
+ url,
347
+ name: file.name,
348
+ meta: { size: file.size, mimeType: file.type },
349
+ };
350
+ },
351
+ { signal, maxRetries: 2 },
352
+ );
353
+ ```
354
+
355
+ ### Resolving assets at export time
356
+
357
+ ```ts
358
+ import {
359
+ createAssetRegistry,
360
+ createIRAssetResolver,
361
+ resolveAssets,
362
+ } from "@anvilkit/plugin-asset-manager";
363
+
364
+ const registry = createAssetRegistry();
365
+ registry.register({
366
+ id: "logo",
367
+ url: "https://cdn.example.com/logo.svg",
368
+ name: "logo.svg",
115
369
  });
370
+
371
+ const resolver = createIRAssetResolver({ registry });
372
+ const resolved = resolveAssets(ir, resolver);
373
+ // `asset://logo` references inside `ir` are now full URLs.
116
374
  ```
117
375
 
118
- The adapter POSTs `{ name, type, size }` to `presignEndpoint`, expects `{ url, publicUrl?, headers?, id? }` back, then PUTs the file to `url`. Both phases retry on 5xx and network errors with exponential backoff. 4xx responses fail without retry.
376
+ ## Notes & FAQ
377
+
378
+ ### Trust model is strict by default
119
379
 
120
- If the host endpoint is **not** S3-compatible (e.g. a custom uploader without overwrite semantics), pass `retry: { maxRetries: 0 }` to disable retry and avoid the small chance of a duplicate upload.
380
+ `UploadAdapter.url` is treated as untrusted input. Default scheme set is `http`, `https`, `blob`. `data:` is **off by default** enable it with `dataUrlAllowlistOptIn: true`. Mixed-script hostnames are rejected unless `allowMixedScriptHostnames: true`. The plugin will not let an adapter smuggle a `javascript:` URL into the registry under any configuration.
121
381
 
122
- The adapter never logs file contents — only `name`, `size`, and `mimeType`.
382
+ ### Migrating from the alpha `urlAllowlist` field
123
383
 
124
- ## Production checklist
384
+ The alpha-era `urlAllowlist?: readonly string[]` field is removed.
125
385
 
126
- Before mounting in a real Studio host:
386
+ | Alpha | Replacement |
387
+ | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
388
+ | `urlAllowlist: ["http", "https", "blob"]` | _(default — drop the field)_ |
389
+ | `urlAllowlist: ["http", "https", "blob", "data"]` | `dataUrlAllowlistOptIn: true` |
390
+ | `urlAllowlist: ["http", "https", "blob", "ftp"]` | _not supported — wrap `validateUploadResult` and call `registry.register` directly_ |
127
391
 
128
- 1. **Pick the right adapter.** `dataUrlUploader` is dev-only (size cap, in-memory). `s3PresignedAdapter` is the production default; wire it to your presign endpoint and set `retry: { maxRetries: 3 }`.
129
- 2. **Set the trust flags explicitly.** Decide whether your host needs `dataUrlAllowlistOptIn: true` (only if your editor flow embeds `data:` URLs end-to-end). Leave `allowMixedScriptHostnames` off unless you have a documented business need.
392
+ Rationale: every scheme allowed at the boundary needs its own CSP / sanitization story. Typed flags force that decision to be explicit.
393
+
394
+ ### Batch upload behavior
395
+
396
+ `StudioAssetSource.upload(files)` runs up to `maxConcurrentUploads` (default 3) uploads in parallel. Per-file failures surface via the listener as `error` envelopes; the returned promise resolves with the successful subset and **does not throw**. Hosts that need fail-fast semantics can pass `maxConcurrentUploads: 1`. `AbortError`s from the adapter abort the whole batch — pending files are not scheduled.
397
+
398
+ ### Persistence is host-owned
399
+
400
+ The plugin keeps registry state in-memory per Studio mount. If you need cross-session asset reuse, the host stores `publicUrl`s server-side and re-seeds the registry on boot via `registry.register(...)`.
401
+
402
+ ### S3 adapter never logs file contents
403
+
404
+ Only `name`, `size`, and `mimeType` are considered safe to log. If your host endpoint is not S3-compatible (no overwrite semantics), pass `retry: { maxRetries: 0 }` to disable retry and avoid the small chance of a duplicate upload.
405
+
406
+ ### Production checklist
407
+
408
+ 1. **Pick the right adapter.** `dataUrlUploader` is dev-only. `s3PresignedAdapter` is the production default; wire it to your presign endpoint and set `retry: { maxRetries: 3 }`.
409
+ 2. **Set the trust flags explicitly.** Decide whether your host needs `dataUrlAllowlistOptIn: true` (only if your flow embeds `data:` URLs end-to-end). Leave `allowMixedScriptHostnames` off unless you have a documented business need.
130
410
  3. **Wire CSP.** Call `getRequiredCsp(...)` and merge the result into your `connect-src` / `img-src` / `media-src` builder. Re-run when you add or remove an adapter.
131
- 4. **Choose a persistence story.** The plugin keeps state in-memory per Studio mount. If you need cross-session asset reuse, the host stores `publicUrl`s server-side and re-seeds the registry on boot via `registry.register`.
132
- 5. **Decide on monitoring.** Subscribe to the `asset-manager:error` event bus envelopes (`code`, `message`) for upload failures, and log `AssetResolutionError.code` from your export pipeline so `ASSET_NOT_FOUND` / `ASSET_URL_REJECTED` / `ASSET_VALIDATION_FAILED` get separate alerts.
133
- 6. **Lock down the bundle.** Consult `.size-limit.json` the headless entry stays under 6 KB gzip; the UI subpath stays under 12 KB. CI gates both.
134
-
135
- ## Error codes
136
-
137
- `AssetValidationError.code` (raised by upload + validation):
138
-
139
- | Code | Source |
140
- | ------------------------------ | ------------------------------- |
141
- | `FILE_TOO_LARGE` | `validateSelectedFile` |
142
- | `UNSUPPORTED_MIME_TYPE` | `validateSelectedFile` |
143
- | `INVALID_UPLOAD_ID` | `validateUploadResult` |
144
- | `EMPTY_UPLOAD_URL` | `validateUploadResult` |
145
- | `UNSCHEMED_UPLOAD_URL` | `validateUploadResult` |
146
- | `DISALLOWED_UPLOAD_URL_SCHEME` | `validateUploadResult` |
147
- | `PATH_TRAVERSAL_URL` | `validateUploadResult` |
148
- | `MIXED_SCRIPT_HOSTNAME` | `validateUploadResult` |
149
- | `DATA_URL_FILE_TOO_LARGE` | `dataUrlUploader` |
150
- | `DATA_URL_READ_FAILED` | `dataUrlUploader` |
151
- | `UPLOAD_FAILED` | `uploadAsset` fallback wrap |
152
-
153
- `AssetResolutionError.code` (raised by `IRAssetResolver`):
154
-
155
- | Code | Meaning |
156
- | -------------------------- | -------------------------------------------------- |
157
- | `ASSET_NOT_FOUND` | The registry has no entry for the asset id. |
158
- | `ASSET_URL_REJECTED` | The stored URL failed the allowlist or trust gate. |
159
- | `ASSET_VALIDATION_FAILED` | Catch-all for unexpected resolver failures. |
411
+ 4. **Choose a persistence story.** Plugin state is in-memory; host stores `publicUrl`s server-side and re-seeds on boot.
412
+ 5. **Monitor.** Subscribe to `asset-manager:error` event bus envelopes for upload failures, and log `AssetResolutionError.code` from your export pipeline so `ASSET_NOT_FOUND` / `ASSET_URL_REJECTED` / `ASSET_VALIDATION_FAILED` get separate alerts.
413
+ 6. **Lock down the bundle.** `.size-limit.json` keeps the headless entry under 6 KB gzip and the UI subpath under 12 KB. CI gates both.
414
+
415
+ ### Optional UI is a separate entry
416
+
417
+ Importing from `@anvilkit/plugin-asset-manager` never pulls the `/ui` components. Hosts that ship their own browser/upload UI pay no UI rendering cost.
@@ -32,11 +32,15 @@ const DEFAULT_MAX_BYTES = 1048576;
32
32
  function dataUrlUploader(options = {}) {
33
33
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
34
34
  let counter = 0;
35
- return async (file)=>{
35
+ return async (file, opts)=>{
36
36
  if (file.size > maxBytes) throw new external_errors_cjs_namespaceObject.AssetValidationError("DATA_URL_FILE_TOO_LARGE", `File size ${file.size} bytes exceeds the data URL adapter limit of ${maxBytes} bytes.`);
37
37
  counter += 1;
38
38
  const url = await readAsDataUrl(file);
39
- const dimensions = await (0, external_extract_image_dimensions_cjs_namespaceObject.extractImageDimensions)(url, file.type);
39
+ const dimensions = await (0, external_extract_image_dimensions_cjs_namespaceObject.extractImageDimensions)(url, file.type, {
40
+ ...opts?.signal ? {
41
+ signal: opts.signal
42
+ } : {}
43
+ });
40
44
  const result = {
41
45
  id: `asset-${counter}`,
42
46
  url,
@@ -1 +1 @@
1
- {"version":3,"file":"data-url.d.cts","sourceRoot":"","sources":["../../src/adapters/data-url.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,aAAa,CAAC;AAG/D,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAID,wBAAgB,eAAe,CAC9B,OAAO,GAAE,sBAA2B,GAClC,aAAa,CA6Bf"}
1
+ {"version":3,"file":"data-url.d.cts","sourceRoot":"","sources":["../../src/adapters/data-url.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,aAAa,CAAC;AAG/D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAID,wBAAgB,eAAe,CAC7B,OAAO,GAAE,sBAA2B,GACnC,aAAa,CA+Bf"}
@@ -1 +1 @@
1
- {"version":3,"file":"data-url.d.ts","sourceRoot":"","sources":["../../src/adapters/data-url.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,aAAa,CAAC;AAG/D,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AAID,wBAAgB,eAAe,CAC9B,OAAO,GAAE,sBAA2B,GAClC,aAAa,CA6Bf"}
1
+ {"version":3,"file":"data-url.d.ts","sourceRoot":"","sources":["../../src/adapters/data-url.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,aAAa,CAAC;AAG/D,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAID,wBAAgB,eAAe,CAC7B,OAAO,GAAE,sBAA2B,GACnC,aAAa,CA+Bf"}
@@ -4,11 +4,15 @@ const DEFAULT_MAX_BYTES = 1048576;
4
4
  function dataUrlUploader(options = {}) {
5
5
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
6
6
  let counter = 0;
7
- return async (file)=>{
7
+ return async (file, opts)=>{
8
8
  if (file.size > maxBytes) throw new AssetValidationError("DATA_URL_FILE_TOO_LARGE", `File size ${file.size} bytes exceeds the data URL adapter limit of ${maxBytes} bytes.`);
9
9
  counter += 1;
10
10
  const url = await readAsDataUrl(file);
11
- const dimensions = await extractImageDimensions(url, file.type);
11
+ const dimensions = await extractImageDimensions(url, file.type, {
12
+ ...opts?.signal ? {
13
+ signal: opts.signal
14
+ } : {}
15
+ });
12
16
  const result = {
13
17
  id: `asset-${counter}`,
14
18
  url,
@@ -31,17 +31,26 @@ async function extractImageDimensions(url, mimeType, options = {}) {
31
31
  if (!mimeType || !mimeType.startsWith("image/")) return;
32
32
  if ("u" < typeof Image) return;
33
33
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
+ const signal = options.signal;
35
+ if (signal?.aborted) return;
34
36
  return new Promise((resolve)=>{
35
37
  let settled = false;
36
38
  const image = new Image();
39
+ const onAbort = ()=>{
40
+ settle(void 0);
41
+ };
37
42
  const settle = (value)=>{
38
43
  if (settled) return;
39
44
  settled = true;
40
45
  image.onload = null;
41
46
  image.onerror = null;
42
47
  if (void 0 !== timer) clearTimeout(timer);
48
+ signal?.removeEventListener("abort", onAbort);
43
49
  resolve(value);
44
50
  };
51
+ signal?.addEventListener("abort", onAbort, {
52
+ once: true
53
+ });
45
54
  image.onload = ()=>{
46
55
  const width = Math.round(image.naturalWidth);
47
56
  const height = Math.round(image.naturalHeight);
@@ -12,6 +12,12 @@
12
12
  export interface ExtractImageDimensionsOptions {
13
13
  /** Abort decode after this many ms. Default 3000. */
14
14
  readonly timeoutMs?: number;
15
+ /**
16
+ * Cancels the in-flight decode. On abort the timer is cleared, the
17
+ * `Image` handlers are detached, and the promise resolves `undefined`
18
+ * (decode is best-effort — abort is not an error here).
19
+ */
20
+ readonly signal?: AbortSignal;
15
21
  }
16
22
  export interface ImageDimensions {
17
23
  readonly width: number;
@@ -1 +1 @@
1
- {"version":3,"file":"extract-image-dimensions.d.cts","sourceRoot":"","sources":["../../src/adapters/extract-image-dimensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,6BAA6B;IAC7C,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAID,wBAAsB,sBAAsB,CAC3C,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,OAAO,GAAE,6BAAkC,GACzC,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAsDtC"}
1
+ {"version":3,"file":"extract-image-dimensions.d.cts","sourceRoot":"","sources":["../../src/adapters/extract-image-dimensions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,6BAA6B;IAC5C,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAID,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,OAAO,GAAE,6BAAkC,GAC1C,OAAO,CAAC,eAAe,GAAG,SAAS,CAAC,CAkEtC"}
@@ -12,6 +12,12 @@
12
12
  export interface ExtractImageDimensionsOptions {
13
13
  /** Abort decode after this many ms. Default 3000. */
14
14
  readonly timeoutMs?: number;
15
+ /**
16
+ * Cancels the in-flight decode. On abort the timer is cleared, the
17
+ * `Image` handlers are detached, and the promise resolves `undefined`
18
+ * (decode is best-effort — abort is not an error here).
19
+ */
20
+ readonly signal?: AbortSignal;
15
21
  }
16
22
  export interface ImageDimensions {
17
23
  readonly width: number;