@anvilkit/plugin-asset-manager 0.1.2 → 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.
- package/README.md +366 -108
- package/dist/adapters/data-url.cjs +6 -2
- package/dist/adapters/data-url.d.cts.map +1 -1
- package/dist/adapters/data-url.d.ts.map +1 -1
- package/dist/adapters/data-url.js +6 -2
- package/dist/adapters/extract-image-dimensions.cjs +9 -0
- package/dist/adapters/extract-image-dimensions.d.cts +6 -0
- package/dist/adapters/extract-image-dimensions.d.cts.map +1 -1
- package/dist/adapters/extract-image-dimensions.d.ts +6 -0
- package/dist/adapters/extract-image-dimensions.d.ts.map +1 -1
- package/dist/adapters/extract-image-dimensions.js +9 -0
- package/dist/adapters/in-memory.cjs +6 -4
- package/dist/adapters/in-memory.d.cts +6 -0
- package/dist/adapters/in-memory.d.cts.map +1 -1
- package/dist/adapters/in-memory.d.ts +6 -0
- package/dist/adapters/in-memory.d.ts.map +1 -1
- package/dist/adapters/in-memory.js +6 -4
- package/dist/adapters/s3-presigned.cjs +78 -28
- package/dist/adapters/s3-presigned.d.cts +1 -0
- package/dist/adapters/s3-presigned.d.cts.map +1 -1
- package/dist/adapters/s3-presigned.d.ts +1 -0
- package/dist/adapters/s3-presigned.d.ts.map +1 -1
- package/dist/adapters/s3-presigned.js +78 -28
- package/dist/csp.d.cts.map +1 -1
- package/dist/csp.d.ts.map +1 -1
- package/dist/errors.d.cts.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin.cjs +32 -5
- package/dist/plugin.d.cts +4 -3
- package/dist/plugin.d.cts.map +1 -1
- package/dist/plugin.d.ts +4 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +22 -5
- package/dist/registry.d.cts.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/resolver.cjs +6 -1
- package/dist/resolver.d.cts.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +6 -1
- package/dist/retry.cjs +10 -8
- package/dist/retry.d.cts.map +1 -1
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +10 -8
- package/dist/studio-asset-source.cjs +26 -5
- package/dist/studio-asset-source.d.cts +2 -2
- package/dist/studio-asset-source.d.cts.map +1 -1
- package/dist/studio-asset-source.d.ts +2 -2
- package/dist/studio-asset-source.d.ts.map +1 -1
- package/dist/studio-asset-source.js +26 -5
- package/dist/testing/index.d.cts.map +1 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/types.d.cts +9 -1
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/AssetBrowser.cjs +54 -19
- package/dist/ui/AssetBrowser.d.cts +10 -1
- package/dist/ui/AssetBrowser.d.cts.map +1 -1
- package/dist/ui/AssetBrowser.d.ts +10 -1
- package/dist/ui/AssetBrowser.d.ts.map +1 -1
- package/dist/ui/AssetBrowser.js +54 -19
- package/dist/ui/AssetCommandPalette.cjs +18 -9
- package/dist/ui/AssetCommandPalette.d.cts.map +1 -1
- package/dist/ui/AssetCommandPalette.d.ts.map +1 -1
- package/dist/ui/AssetCommandPalette.js +18 -9
- package/dist/ui/AssetManagerUI.cjs +1 -0
- package/dist/ui/AssetManagerUI.d.cts.map +1 -1
- package/dist/ui/AssetManagerUI.d.ts.map +1 -1
- package/dist/ui/AssetManagerUI.js +1 -0
- package/dist/ui/DeleteAssetDialog.cjs +1 -0
- package/dist/ui/DeleteAssetDialog.d.cts.map +1 -1
- package/dist/ui/DeleteAssetDialog.d.ts.map +1 -1
- package/dist/ui/DeleteAssetDialog.js +1 -0
- package/dist/ui/MetadataPanel.cjs +1 -0
- package/dist/ui/MetadataPanel.d.cts.map +1 -1
- package/dist/ui/MetadataPanel.d.ts.map +1 -1
- package/dist/ui/MetadataPanel.js +1 -0
- package/dist/ui/ReplaceAssetDialog.cjs +1 -0
- package/dist/ui/ReplaceAssetDialog.d.cts.map +1 -1
- package/dist/ui/ReplaceAssetDialog.d.ts.map +1 -1
- package/dist/ui/ReplaceAssetDialog.js +1 -0
- package/dist/ui/UploadButton.cjs +22 -7
- package/dist/ui/UploadButton.d.cts.map +1 -1
- package/dist/ui/UploadButton.d.ts.map +1 -1
- package/dist/ui/UploadButton.js +22 -7
- package/dist/ui/index.d.cts.map +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/validate-upload-result.cjs +49 -10
- package/dist/validate-upload-result.d.cts.map +1 -1
- package/dist/validate-upload-result.d.ts.map +1 -1
- package/dist/validate-upload-result.js +49 -10
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,159 +1,417 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @anvilkit/plugin-asset-manager
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
+
## Core features
|
|
33
42
|
|
|
34
|
-
|
|
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
|
-
##
|
|
52
|
+
## API reference
|
|
37
53
|
|
|
38
|
-
|
|
54
|
+
### Plugin factory
|
|
39
55
|
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
### `AssetRegistry`
|
|
57
115
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
Sidebar consumers can also call `inferStudioAssetKind(entry)` directly.
|
|
67
165
|
|
|
68
|
-
|
|
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
|
-
|
|
174
|
+
interface CreateIRAssetResolverOptions {
|
|
175
|
+
readonly registry: AssetRegistry;
|
|
176
|
+
readonly dataUrlAllowlistOptIn?: boolean;
|
|
177
|
+
readonly allowMixedScriptHostnames?: boolean;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
72
180
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
181
|
+
### Validation & security
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
function validateUploadResult(
|
|
185
|
+
result: UploadResult,
|
|
186
|
+
options?: ValidateUploadResultOptions,
|
|
187
|
+
): UploadResult;
|
|
80
188
|
```
|
|
81
189
|
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
+
### CSP advisor
|
|
91
198
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
-
|
|
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
|
-
|
|
209
|
+
### React UI (`./ui`)
|
|
99
210
|
|
|
100
|
-
|
|
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
|
-
`
|
|
221
|
+
`UploadProgressSnapshot` is `{ inFlight: number; completed: number }`.
|
|
103
222
|
|
|
104
|
-
|
|
223
|
+
### Retry helpers (`./retry`)
|
|
105
224
|
|
|
106
225
|
```ts
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
376
|
+
## Notes & FAQ
|
|
377
|
+
|
|
378
|
+
### Trust model is strict by default
|
|
119
379
|
|
|
120
|
-
|
|
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
|
-
|
|
382
|
+
### Migrating from the alpha `urlAllowlist` field
|
|
123
383
|
|
|
124
|
-
|
|
384
|
+
The alpha-era `urlAllowlist?: readonly string[]` field is removed.
|
|
125
385
|
|
|
126
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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.**
|
|
132
|
-
5. **
|
|
133
|
-
6. **Lock down the bundle.**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
`
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|