@zerotal/arch 1.7.0
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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
package/docs/media.md
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Media Library
|
|
3
|
+
description: Attach files to models with collections, image conversions, responsive images and ordering, on any storage disk.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Media Library
|
|
7
|
+
|
|
8
|
+
Attach files to a model and let the framework handle the rest — where the bytes
|
|
9
|
+
live, the database row that points at them, the thumbnails, the URLs, the
|
|
10
|
+
ordering, and the cleanup when the model goes away.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
const media = await product.addMedia(await ctx.file("photo")).toCollection("images");
|
|
14
|
+
|
|
15
|
+
media.getUrl(); // the original
|
|
16
|
+
media.getUrl("thumb"); // a generated conversion
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Getting started
|
|
20
|
+
|
|
21
|
+
Install the package and register its provider:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun add @zerotal/media
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// bootstrap/providers.ts
|
|
29
|
+
import { StorageProvider } from "zerotal/storage";
|
|
30
|
+
import { MediaProvider } from "zerotal/media";
|
|
31
|
+
|
|
32
|
+
export default [
|
|
33
|
+
DatabaseProvider,
|
|
34
|
+
StorageProvider, // media writes through disks — register it too
|
|
35
|
+
MediaProvider,
|
|
36
|
+
];
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
There is no migration to write. `MediaProvider` provisions the `media` table on
|
|
40
|
+
boot, once, only when it is missing. Set `autoCreateTable: false` in config if
|
|
41
|
+
you would rather own the schema yourself.
|
|
42
|
+
|
|
43
|
+
## Declaring collections
|
|
44
|
+
|
|
45
|
+
A model gains media by composing the `Media` mixin and declaring the
|
|
46
|
+
collections it owns:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// app/models/Product.ts
|
|
50
|
+
import { Model, column } from "zerotal/orm";
|
|
51
|
+
import { Media, type MediaCollections } from "zerotal/media";
|
|
52
|
+
|
|
53
|
+
export class Product extends Model.using(Media) {
|
|
54
|
+
@column() name!: string;
|
|
55
|
+
|
|
56
|
+
static override mediaCollections: MediaCollections = {
|
|
57
|
+
images: {
|
|
58
|
+
accepts: ["image/jpeg", "image/png", "image/webp"],
|
|
59
|
+
conversions: {
|
|
60
|
+
thumb: { width: 200, height: 200, format: "webp" },
|
|
61
|
+
hero: { width: 1600, format: "webp", queued: true },
|
|
62
|
+
},
|
|
63
|
+
responsive: true,
|
|
64
|
+
},
|
|
65
|
+
manual: {
|
|
66
|
+
single: true,
|
|
67
|
+
accepts: ["application/pdf"],
|
|
68
|
+
fallbackUrl: "/img/no-manual.svg",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A collection has to be declared before anything can go into it. An undeclared
|
|
75
|
+
name throws, listing the ones that do exist — because the alternative is a typo
|
|
76
|
+
that silently creates a collection nobody ever reads from.
|
|
77
|
+
|
|
78
|
+
> **`Media` and `MediaItem`.** `Media` is the mixin — the thing a model _uses_,
|
|
79
|
+
> which is why it reads as `Model.using(Media)`. `MediaItem` is one stored file:
|
|
80
|
+
> a row in the `media` table, with its own URL, conversions and custom
|
|
81
|
+
> properties. `getMedia()` returns `MediaItem[]`.
|
|
82
|
+
|
|
83
|
+
### Collection options
|
|
84
|
+
|
|
85
|
+
| Option | What it does |
|
|
86
|
+
| ------------------------------ | -------------------------------------------------------- |
|
|
87
|
+
| `disk` / `conversionsDisk` | Where originals and derivatives are written |
|
|
88
|
+
| `accepts` | Allowed MIME types, checked against the file's own bytes |
|
|
89
|
+
| `maxSize` | Largest accepted file, in bytes |
|
|
90
|
+
| `single` | A second file replaces the first |
|
|
91
|
+
| `onlyKeepLatest` | Keep the _n_ newest, deleting older ones |
|
|
92
|
+
| `fallbackUrl` / `fallbackPath` | Returned when the collection is empty |
|
|
93
|
+
| `conversions` | Derived images to generate |
|
|
94
|
+
| `responsive` | `true`, or an explicit array of widths |
|
|
95
|
+
|
|
96
|
+
## Adding files
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// From an upload
|
|
100
|
+
await product.addMedia(await ctx.file("photo")).toCollection("images");
|
|
101
|
+
|
|
102
|
+
// From elsewhere
|
|
103
|
+
await product.addMediaFromUrl("https://example.com/a.jpg").toCollection("images");
|
|
104
|
+
await product.addMediaFromDisk("tmp/a.jpg", "local").toCollection("images");
|
|
105
|
+
await product.addMediaFromPath("/var/import/a.jpg").toCollection("images");
|
|
106
|
+
|
|
107
|
+
// Copy one item onto another model — independent bytes, fresh uuid
|
|
108
|
+
await draft.copyMedia(original).toCollection("images");
|
|
109
|
+
|
|
110
|
+
// With metadata
|
|
111
|
+
await product
|
|
112
|
+
.addMedia(file)
|
|
113
|
+
.usingName("Front view")
|
|
114
|
+
.withCustomProperties({ alt: "Front view", credit: "Studio B" })
|
|
115
|
+
.toCollection("images");
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Nothing is read, validated or written until `toCollection()` is awaited.
|
|
119
|
+
|
|
120
|
+
### The type comes from the bytes
|
|
121
|
+
|
|
122
|
+
`accepts` is checked against the type sniffed from the file's own contents, not
|
|
123
|
+
the filename or the upload's `Content-Type` header. Both of those are supplied
|
|
124
|
+
by whoever is uploading, so a `payload.html` renamed to `photo.jpg` and sent as
|
|
125
|
+
`image/jpeg` is still rejected. The stored extension and `Content-Type` are
|
|
126
|
+
derived the same way.
|
|
127
|
+
|
|
128
|
+
## Reading
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
await product.getMedia("images"); // MediaItem[], in order
|
|
132
|
+
await product.getFirstMedia("images"); // MediaItem | null
|
|
133
|
+
await product.getFirstMediaUrl("images"); // or the collection's fallbackUrl
|
|
134
|
+
await product.getFirstMediaUrl("images", "thumb");
|
|
135
|
+
await product.hasMedia("images");
|
|
136
|
+
await product.mediaCount("images");
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`getFirstMediaUrl()` returns `""` when there is nothing and no fallback, so it
|
|
140
|
+
goes straight into `src` without a null check. Asking for a conversion that has
|
|
141
|
+
not been generated yet — a queued one still waiting on a worker — falls back to
|
|
142
|
+
the original rather than to nothing.
|
|
143
|
+
|
|
144
|
+
## Conversions
|
|
145
|
+
|
|
146
|
+
Conversions are declared per collection and generated when a file is added:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
conversions: {
|
|
150
|
+
thumb: { width: 200, height: 200, format: "webp", quality: 80 },
|
|
151
|
+
hero: { width: 1600, queued: true },
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Field | Meaning |
|
|
156
|
+
| ------------------ | -------------------------------------------------- |
|
|
157
|
+
| `width` / `height` | Target box |
|
|
158
|
+
| `fit` | `inside` (default), `fill`, or `cover` |
|
|
159
|
+
| `format` | `jpeg`, `png`, `webp` |
|
|
160
|
+
| `quality` | 1–100 |
|
|
161
|
+
| `rotate` | Degrees, applied before resizing |
|
|
162
|
+
| `allowEnlargement` | Scale small sources up to the box. Default `false` |
|
|
163
|
+
| `queued` | Generate on a worker instead of in the request |
|
|
164
|
+
|
|
165
|
+
Read them back with `media.getUrl("thumb")`, or `""` when that conversion does
|
|
166
|
+
not exist. `media.hasConversion("thumb")` when you need to branch.
|
|
167
|
+
|
|
168
|
+
### How `fit` behaves
|
|
169
|
+
|
|
170
|
+
`cover` scales the image so it overflows the target box in at most one axis,
|
|
171
|
+
then keeps the centre — the square thumbnail from a 3:2 photograph. It needs
|
|
172
|
+
both `width` and `height`; given one, there is nothing to crop away and it
|
|
173
|
+
behaves as `inside`.
|
|
174
|
+
|
|
175
|
+
By default nothing is ever scaled up. If the source is too small to fill the
|
|
176
|
+
box, `cover` returns the largest centre window the source can supply, which may
|
|
177
|
+
not have the aspect ratio you asked for — a 300×500 source covering a 400×400
|
|
178
|
+
box gives 300×400, not 300×300. Set `allowEnlargement: true` on the conversion
|
|
179
|
+
when the exact box matters more than fidelity.
|
|
180
|
+
|
|
181
|
+
All of this works on the default driver, which is `Bun.Image` — built into the
|
|
182
|
+
runtime, no native module, nothing to install.
|
|
183
|
+
|
|
184
|
+
### Choosing an image driver
|
|
185
|
+
|
|
186
|
+
`Bun.Image` (the default) and `sharp` support the same manipulations and are
|
|
187
|
+
held to the same output dimensions by a shared parity suite, so switching is a
|
|
188
|
+
performance decision rather than a feature one:
|
|
189
|
+
|
|
190
|
+
| | `bun` (default) | `sharp` |
|
|
191
|
+
| --------------------------- | ----------------------- | -------------------------------- |
|
|
192
|
+
| Install | Nothing | `bun add sharp`, a native module |
|
|
193
|
+
| `fit: "cover"` | Yes | Yes |
|
|
194
|
+
| Throughput on large batches | Good | Better — libvips |
|
|
195
|
+
| AVIF / HEIC | Host codecs (see below) | Bundled |
|
|
196
|
+
|
|
197
|
+
Switch with:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// config/media.ts
|
|
201
|
+
import { MediaConfig } from "zerotal/media";
|
|
202
|
+
export default MediaConfig({ driver: "sharp" });
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### AVIF and HEIC are host-dependent
|
|
206
|
+
|
|
207
|
+
`Bun.Image` encodes AVIF and HEIC through OS codecs that are missing on most
|
|
208
|
+
Linux hosts. They are off by default; set `allowHostFormats: true` to use them,
|
|
209
|
+
and the boot log will warn if this machine cannot. Stick to `jpeg`, `png` and
|
|
210
|
+
`webp` and the output is identical everywhere.
|
|
211
|
+
|
|
212
|
+
### Queued conversions
|
|
213
|
+
|
|
214
|
+
Mark a conversion `queued: true` and it runs on a worker instead of in the
|
|
215
|
+
request. This needs `@zerotal/queue` registered; with no queue bound, every
|
|
216
|
+
conversion runs inline — late is better than a thumbnail that never appears.
|
|
217
|
+
|
|
218
|
+
## Responsive images
|
|
219
|
+
|
|
220
|
+
Set `responsive: true` on a collection to generate a width ladder plus an inline
|
|
221
|
+
blur placeholder:
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
<img
|
|
225
|
+
src={media.getUrl()}
|
|
226
|
+
srcset={media.srcset()}
|
|
227
|
+
sizes="(max-width: 768px) 100vw, 50vw"
|
|
228
|
+
style={{ backgroundImage: `url(${media.placeholder})` }}
|
|
229
|
+
/>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Widths wider than the source are skipped rather than upscaled. The placeholder
|
|
233
|
+
is a ThumbHash-rendered data URI of a few hundred bytes — no client-side decoder
|
|
234
|
+
needed.
|
|
235
|
+
|
|
236
|
+
## Custom properties
|
|
237
|
+
|
|
238
|
+
Any JSON you attach travels with the row:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
media.getCustomProperty("alt");
|
|
242
|
+
media.setCustomProperty("alt", "A steel kettle");
|
|
243
|
+
await media.save();
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Ordering
|
|
247
|
+
|
|
248
|
+
Items carry an `orderColumn`, assigned in insertion order:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
await product.setMediaOrder([third.id, first.id], "images");
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Ids you leave out keep their relative order after the ones you list, so handing
|
|
255
|
+
in only the items a drag-and-drop UI moved does what it looks like.
|
|
256
|
+
|
|
257
|
+
## Deleting
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
await media.delete(); // row + original + every derivative
|
|
261
|
+
await product.clearMediaCollection("images");
|
|
262
|
+
await product.clearAllMedia();
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Hard-deleting a model deletes its files too. A model using `SoftDeletes` keeps
|
|
266
|
+
them — `restore()` is supposed to give back the model you had, and it cannot do
|
|
267
|
+
that if the images went with it. Those files go on `forceDelete()`.
|
|
268
|
+
|
|
269
|
+
## Private files
|
|
270
|
+
|
|
271
|
+
Media inherits whatever the disk does. Put a collection on a private disk and
|
|
272
|
+
hand out signed, expiring links instead of public URLs:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
await media.getTemporaryUrl(300); // the original, for 5 minutes
|
|
276
|
+
await media.getTemporaryUrl(300, "thumb"); // a conversion
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
See [Storage](storage.md) for how disks are configured and served.
|
|
280
|
+
|
|
281
|
+
## Where files live
|
|
282
|
+
|
|
283
|
+
```text
|
|
284
|
+
media/<uuid>/original.jpg
|
|
285
|
+
media/<uuid>/conversions/thumb.webp
|
|
286
|
+
media/<uuid>/responsive/640.webp
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Each item gets its own directory keyed on its uuid, not its numeric id: these
|
|
290
|
+
paths end up in public URLs, and a sequential id there tells everyone how many
|
|
291
|
+
rows the table has. Supply a `PathGenerator` to change the layout.
|
|
292
|
+
|
|
293
|
+
## Commands
|
|
294
|
+
|
|
295
|
+
```bash
|
|
296
|
+
bun zt media:clean # report rows whose files are missing
|
|
297
|
+
bun zt media:clean --force # and delete them
|
|
298
|
+
bun zt media:regenerate # rebuild every conversion
|
|
299
|
+
bun zt media:regenerate --model=Product --only=thumb
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Run `media:regenerate` after changing a conversion's definition — existing files
|
|
303
|
+
are not reprocessed automatically.
|
|
304
|
+
|
|
305
|
+
## Testing
|
|
306
|
+
|
|
307
|
+
Pair `Storage.fake()` with `MediaFake`. The first asserts bytes landed, the
|
|
308
|
+
second asserts a row points at them — a media row with no file and a file with
|
|
309
|
+
no row are different bugs.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { Storage } from "zerotal/storage";
|
|
313
|
+
import { MediaFake } from "zerotal/media";
|
|
314
|
+
|
|
315
|
+
const disk = Storage.fake();
|
|
316
|
+
|
|
317
|
+
await product.addMedia(file).toCollection("images");
|
|
318
|
+
|
|
319
|
+
await MediaFake.assertHas(product, "images");
|
|
320
|
+
await MediaFake.assertCount(product, "images", 1);
|
|
321
|
+
await MediaFake.assertConversion(product, "images", "thumb");
|
|
322
|
+
disk.assertExistsMatching(/^media\/[0-9a-f-]+\/original\.png$/);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Configuration
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
// config/media.ts
|
|
329
|
+
import { MediaConfig } from "zerotal/media";
|
|
330
|
+
|
|
331
|
+
export default MediaConfig({
|
|
332
|
+
disk: "s3",
|
|
333
|
+
driver: "bun",
|
|
334
|
+
quality: 82,
|
|
335
|
+
format: "webp",
|
|
336
|
+
responsiveWidths: [320, 640, 960, 1280, 1920],
|
|
337
|
+
maxConversionInputSize: 32 * 1024 * 1024,
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`maxConversionInputSize` is a real limit, not a formality: `Bun.Image` has no
|
|
342
|
+
streaming API, so decoding buffers the whole file. Originals above the ceiling
|
|
343
|
+
are still stored — they just get no conversions.
|
|
344
|
+
|
|
345
|
+
## API reference
|
|
346
|
+
|
|
347
|
+
Signatures below are the ones `packages/media/api-surface.md` records, which CI diffs on every change. Anything importable and not listed here is `@internal`: it exists because a module inside the package needed it, and it is not covered by the stability guarantee.
|
|
348
|
+
|
|
349
|
+
### The mixin, and one stored file
|
|
350
|
+
|
|
351
|
+
`Media` is the mixin — it reads as `Model.using(Media)`, and it declares the static `mediaCollections` field. `MediaItem` is one stored file: a row in the `media` table, and an ordinary model, so every query-builder method is available on it too.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
function Media<TBase extends Constructor>(
|
|
355
|
+
Base: TBase,
|
|
356
|
+
): TBase & { mediaCollections: MediaCollections };
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`MediaItem`'s own members, on top of what a model already gives you:
|
|
360
|
+
|
|
361
|
+
| Member | What it answers |
|
|
362
|
+
| ------------------------------------------------- | ---------------------------------------------------------------------------- |
|
|
363
|
+
| `getUrl(conversion?)` | Public URL of the original, or of a named conversion |
|
|
364
|
+
| `getPath(conversion?)` | Path on the disk, for server-side reads |
|
|
365
|
+
| `getTemporaryUrl(expiresInSeconds?, conversion?)` | Signed, expiring URL — see [Private files](#private-files) |
|
|
366
|
+
| `getResponsivePath(width)` | Path of one rung of the responsive ladder |
|
|
367
|
+
| `srcset()` | A ready `srcset` string built from `responsiveImages` |
|
|
368
|
+
| `responsiveSet()` | The `ResponsiveImageSet` behind it, placeholder included |
|
|
369
|
+
| `bytes()` | The original's bytes |
|
|
370
|
+
| `fileExists()` | Whether the file is actually on the disk — the check `media:clean` automates |
|
|
371
|
+
| `deleteFiles()` | Remove originals, conversions and responsive images, leaving the row |
|
|
372
|
+
| `conversion(name)` / `hasConversion(name)` | One `GeneratedConversion`, or whether it exists |
|
|
373
|
+
| `conversionNames()` | Every conversion generated for this item |
|
|
374
|
+
| `getCustomProperty(key, fallback?)` | A value from `customProperties`, typed by the fallback |
|
|
375
|
+
| `setCustomProperty(key, value)` | Set one — chainable; call `save()` to persist |
|
|
376
|
+
| `forgetCustomProperty(key)` | Drop one — chainable |
|
|
377
|
+
| `originalDisk()` / `derivedDisk()` | The resolved `StorageDriver` for each |
|
|
378
|
+
|
|
379
|
+
Columns: `uuid`, `name`, `fileName`, `mimeType`, `size`, `disk`, `conversionsDisk`, `collectionName`, `modelType`, `modelId`, `orderColumn`, `customProperties`, `manipulations`, `generatedConversions`, `responsiveImages`, `placeholder`.
|
|
380
|
+
|
|
381
|
+
### The adder
|
|
382
|
+
|
|
383
|
+
`addMedia(source)` returns a `MediaAdder`. Every method chains; `toCollection()` is what actually stores the file, and it returns the `MediaItem`.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
class MediaAdder {
|
|
387
|
+
usingName(name: string): MediaAdder;
|
|
388
|
+
usingFileName(fileName: string): MediaAdder;
|
|
389
|
+
withCustomProperties(properties: Record<string, unknown>): MediaAdder;
|
|
390
|
+
withOrder(order: number): MediaAdder;
|
|
391
|
+
toDisk(disk: string): MediaAdder;
|
|
392
|
+
toCollection(collection?: string): Promise<MediaItem>;
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`MediaSource` is what a source may be:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
type MediaSource = ArrayBuffer | Blob | UploadedFile | File | Uint8Array;
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`PendingMediaMeta` is the same metadata as an object, for callers that build it up rather than chaining: `{ name?, customProperties?, order?, disk? }`.
|
|
403
|
+
|
|
404
|
+
`MediaOwner` is the minimum a model must expose to own media — an `id`, and a constructor name, which is what lands in `model_type`.
|
|
405
|
+
|
|
406
|
+
### Application-level operations
|
|
407
|
+
|
|
408
|
+
`MediaLibrary` is the facade; `MediaManager` is the class behind it. They are named differently because `Media` is already the mixin, and an app importing both would otherwise have to rename one at every call site.
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
class MediaManager {
|
|
412
|
+
readonly config: MediaConfigShape;
|
|
413
|
+
readonly driver: ImageDriver;
|
|
414
|
+
clean(options?: { dryRun?: boolean }): Promise<CleanReport>;
|
|
415
|
+
regenerate(media: MediaItem, ownerClass: CollectionHost, only?: string[]): Promise<string[]>;
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`CleanReport` is what a sweep found, and is worth reading rather than counting:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
interface CleanReport {
|
|
423
|
+
/** Rows whose original file is gone from the disk. */
|
|
424
|
+
orphanedRows: number[];
|
|
425
|
+
/** Conversions recorded on a row but missing on the disk. */
|
|
426
|
+
danglingConversions: { mediaId: number; conversion: string }[];
|
|
427
|
+
/** Rows actually removed — empty on a dry run. */
|
|
428
|
+
deletedRows: number[];
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Collections and conversions
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
type MediaCollections = Record<string, CollectionDefinition | (() => CollectionDefinition)>;
|
|
436
|
+
type ConversionMap = Record<string, ConversionDefinition>;
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`CollectionDefinition` is the option set documented under [Collection options](#collection-options). `ConversionDefinition` is one derived image: `width`, `height`, `fit`, `format`, `quality`, `rotate`, `allowEnlargement`, `queued`.
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
type ConversionFit = "inside" | "fill" | "cover";
|
|
443
|
+
type SafeConversionFormat = "jpeg" | "png" | "webp";
|
|
444
|
+
type ConversionFormat = SafeConversionFormat | "avif" | "heic";
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
`SafeConversionFormat` is the set that encodes on every host. `ConversionFormat` adds the two that go through OS codecs — see [AVIF and HEIC are host-dependent](#avif-and-heic-are-host-dependent).
|
|
448
|
+
|
|
449
|
+
What generation records:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
interface GeneratedConversion {
|
|
453
|
+
fileName: string;
|
|
454
|
+
size: number;
|
|
455
|
+
mimeType: string;
|
|
456
|
+
width: number;
|
|
457
|
+
height: number;
|
|
458
|
+
/** ISO-8601. */
|
|
459
|
+
generatedAt: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
interface ResponsiveImage {
|
|
463
|
+
fileName: string;
|
|
464
|
+
width: number;
|
|
465
|
+
height: number;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
interface ResponsiveImageSet {
|
|
469
|
+
/** Generated widths, ascending. */
|
|
470
|
+
images: ResponsiveImage[];
|
|
471
|
+
/** A `data:` low-quality placeholder, when one was produced. */
|
|
472
|
+
placeholder?: string;
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Image drivers
|
|
477
|
+
|
|
478
|
+
`ImageDriver` is the seam between this package and whatever actually manipulates pixels. Two implementations ship — `BunImageDriver` (the default, no dependencies) and `SharpImageDriver` (opt-in, a native module) — and a shared parity suite holds them to the same output dimensions.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
interface ImageDriver {
|
|
482
|
+
readonly name: string;
|
|
483
|
+
/** Whether `fit: "cover"` is available. Both shipped drivers report `true`. */
|
|
484
|
+
readonly supportsCrop: boolean;
|
|
485
|
+
metadata(bytes: Uint8Array): Promise<ImageMetadata | null>;
|
|
486
|
+
convert(bytes: Uint8Array, manipulation: ImageManipulation): Promise<ImageResult>;
|
|
487
|
+
placeholder(bytes: Uint8Array): Promise<string | null>;
|
|
488
|
+
canEncode(format: ConversionFormat): Promise<boolean>;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
interface ImageManipulation {
|
|
492
|
+
width?: number;
|
|
493
|
+
height?: number;
|
|
494
|
+
fit?: ConversionFit;
|
|
495
|
+
/** Always resolved by the caller — drivers never guess. */
|
|
496
|
+
format: ConversionFormat;
|
|
497
|
+
quality?: number;
|
|
498
|
+
rotate?: number;
|
|
499
|
+
/** Never scale a source up to meet the box. Default `true`. */
|
|
500
|
+
withoutEnlargement?: boolean;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
interface ImageResult {
|
|
504
|
+
bytes: Uint8Array;
|
|
505
|
+
width: number;
|
|
506
|
+
height: number;
|
|
507
|
+
format: ConversionFormat;
|
|
508
|
+
mimeType: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
interface ImageMetadata {
|
|
512
|
+
width: number;
|
|
513
|
+
height: number;
|
|
514
|
+
format: string;
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Writing your own driver is supported, and the interface may grow only in ways that leave yours compiling: **new members arrive optional**, with the package supplying the fallback. `ImageManipulation` may gain optional fields; `ImageResult` and `ImageMetadata` may not gain required ones, because drivers produce them.
|
|
519
|
+
|
|
520
|
+
`BunImageDriver` takes pixel ceilings, so a decompression bomb fails as a refusal rather than as memory exhaustion. `SharpImageDriver` takes no arguments — libvips streams tiles rather than materialising the full bitmap, so the ceiling has nothing to protect.
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
new BunImageDriver(maxPixels?, maxCropPixels?);
|
|
524
|
+
new SharpImageDriver();
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
`SharpImageDriver` also loads `sharp` lazily, through a variable specifier, so `tsc` does not try to resolve it in the apps that never installed it — which is most of them. Selecting the driver without the package installed fails at first use with a message naming the install command.
|
|
528
|
+
|
|
529
|
+
Three lookup tables are exported for reading — to label a download, or to check a type before offering an upload. They are frozen, because they are shared module state: an app that mutated one would change how conversions behave for every other caller in the process, including ones it does not own.
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
const FORMAT_MIME: Readonly<Record<ConversionFormat, string>>;
|
|
533
|
+
const FORMAT_EXTENSION: Readonly<Record<ConversionFormat, string>>;
|
|
534
|
+
const CONVERTIBLE_MIME_TYPES: ReadonlySet<string>;
|
|
535
|
+
function isConvertible(mimeType: string | null | undefined): boolean;
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
`isConvertible` answers whether a stored file is worth handing to a driver at all — the check to run before offering a "regenerate thumbnails" button.
|
|
539
|
+
|
|
540
|
+
### Paths
|
|
541
|
+
|
|
542
|
+
Supply a `PathGenerator` to change the on-disk layout described under [Where files live](#where-files-live). `DefaultPathGenerator` is the shipped one, and `setPathGenerator` installs yours. It is process-global, so a provider's `register()` is the place for it.
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
interface PathGenerator {
|
|
546
|
+
forOriginal(media: MediaItem): string;
|
|
547
|
+
forConversions(media: MediaItem): string;
|
|
548
|
+
forResponsiveImages(media: MediaItem): string;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
class DefaultPathGenerator implements PathGenerator {
|
|
552
|
+
constructor(prefix?: string);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function setPathGenerator(generator: PathGenerator): void;
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
// A provider's register()
|
|
560
|
+
setPathGenerator(new DefaultPathGenerator("uploads"));
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
### Command classes
|
|
564
|
+
|
|
565
|
+
`MediaCleanCommand` and `MediaRegenerateCommand` back `media:clean` and `media:regenerate`. `MediaProvider` registers both; they are exported from `@zerotal/media/commands` so an app can subclass one to change its defaults.
|
|
566
|
+
|
|
567
|
+
### Errors
|
|
568
|
+
|
|
569
|
+
Every failure is a `MediaError` subclass carrying a stable `code`, an HTTP `status` and a `context` object — so a handler can branch on the code rather than matching a message.
|
|
570
|
+
|
|
571
|
+
| Class | Code | Status | Raised when |
|
|
572
|
+
| ------------------------------ | ---------------------------------- | ------ | --------------------------------------------------------------- |
|
|
573
|
+
| `UnknownCollectionError` | `E_MEDIA_UNKNOWN_COLLECTION` | 500 | A collection name matches nothing the model declares |
|
|
574
|
+
| `DisallowedMimeTypeError` | `E_MEDIA_DISALLOWED_MIME_TYPE` | 422 | The sniffed type is not in the collection's `accepts` |
|
|
575
|
+
| `FileTooLargeError` | `E_MEDIA_FILE_TOO_LARGE` | 422 | The file exceeds the collection's `maxSize` |
|
|
576
|
+
| `UnsavedOwnerError` | `E_MEDIA_UNSAVED_OWNER` | 500 | `addMedia` on a model with no primary key yet |
|
|
577
|
+
| `UnsupportedFormatError` | `E_MEDIA_UNSUPPORTED_FORMAT` | 500 | This host cannot encode the requested format |
|
|
578
|
+
| `UnsupportedManipulationError` | `E_MEDIA_UNSUPPORTED_MANIPULATION` | 500 | The driver cannot do what the conversion asks (a crop, usually) |
|
|
579
|
+
| `RasterFormatError` | `E_MEDIA_RASTER_FORMAT` | 500 | A decoded image could not be re-encoded |
|
|
580
|
+
| `MediaFileMissingError` | `E_MEDIA_FILE_MISSING` | 404 | A row points at a file the disk does not have |
|
|
581
|
+
|
|
582
|
+
`UnknownCollectionError` lists the collections the model _does_ declare, because the mistake is nearly always a typo.
|