@12-apps/storage 1.0.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.
@@ -0,0 +1,221 @@
1
+ import { ACCEPTED_CONTENT_TYPES } from '../content-types';
2
+ import { DEFAULT_KEY_PREFIX } from '../keys';
3
+ import { megabytes } from '../limits';
4
+ import { defaultStorageMessages, type StorageMessages } from '../problems';
5
+ import { CATALOG_RENDITIONS, type RenditionSpec } from '../renditions';
6
+ import { imageSources, objectUrl, versionedObjectUrl, type ImageSources } from '../urls';
7
+ import { inlineImageSchema, objectKeySchema, type InlineImage } from '../wire';
8
+ import type { StorageDriver } from './driver';
9
+ import type { ImagePipeline } from './pipeline/port';
10
+ import {
11
+ deleteIfOrphaned,
12
+ deleteReplaced,
13
+ discardMinted,
14
+ objectKeysFor,
15
+ type ReclaimDeps,
16
+ type StorageLogger,
17
+ type StorageReferenceProbe,
18
+ } from './reclaim';
19
+ import { storageRoutes, type StorageRoute } from './routes';
20
+ import { storeImage, storeInlineImage, type StoreImageDeps } from './store-image';
21
+
22
+ /**
23
+ * `createApiStorage(config)` — the ONE thing a backend host mounts.
24
+ *
25
+ * Everything the upload system IS lives behind it: the endpoint, the streaming
26
+ * cap, the magic-number check, the image pipeline, the key grammar, the rendition
27
+ * set, the URL builder and the orphan reclaim. The host supplies config only —
28
+ * where bytes live, who is calling, which of its tables can pin an object, and
29
+ * the ceiling.
30
+ *
31
+ * ## Nothing here defaults to a value only one deployment can survive
32
+ *
33
+ * `driver`, `maxBytes`, `imagePipeline`, `unscopedKeys` and `references` are all
34
+ * REQUIRED, and each for the same reason: the safe value is host-specific, so a
35
+ * default would be a decision made by whoever typed the first host, silently
36
+ * inherited by every one after it. A defaulted driver writes to a container's
37
+ * ephemeral disk in production and reports success. A defaulted ceiling is
38
+ * whatever number this file happens to carry. A defaulted pipeline decides
39
+ * whether crops exist at all. A defaulted `unscopedKeys` decides whether one
40
+ * tenant's reclaim can reach another tenant's bytes. A defaulted `references`
41
+ * decides whether a restored row still has its photo.
42
+ *
43
+ * `references` joined that list last, and it is the one worth explaining: it DID
44
+ * default, to `[]`, and `[]` is not the absence of a decision — it means *reclaim
45
+ * everything immediately*. Two fail-open defaults in this series became findings
46
+ * before this one was noticed, and the argument the other four are made of applies
47
+ * to it verbatim. `logger` still defaults, and that is a different case: a reclaim
48
+ * can only report, so the worst a defaulted logger costs is silence. A defaulted
49
+ * `references` DESTROYS.
50
+ */
51
+
52
+ export interface ApiStorageConfig {
53
+ /** Where bytes live. See `./drivers/local-disk` and `@12-apps/storage/s3`. */
54
+ driver: StorageDriver;
55
+ /**
56
+ * The upload ceiling, in bytes. There is exactly one number: pass the same
57
+ * value to any tool schema advertising it, or better, read it back off
58
+ * {@link ApiStorage.limits} so the two cannot drift.
59
+ */
60
+ maxBytes: number;
61
+ /** How bytes are downscaled and cropped. `passthroughImagePipeline()` opts out. */
62
+ imagePipeline: ImagePipeline;
63
+ /**
64
+ * Whether a key with NO scope segment may be touched by a scoped caller.
65
+ *
66
+ * `'accept'` for a host whose existing rows predate the scope segment — such a
67
+ * key carries no tenant, so only `references` stands between one tenant and
68
+ * another's objects, and a host choosing it is saying it has those probes.
69
+ * `'reject'` for a fresh host: every key it will ever mint is scoped, so a key
70
+ * without a scope can only be someone else's or nobody's.
71
+ */
72
+ unscopedKeys: 'accept' | 'reject';
73
+ /**
74
+ * The host tables that can still need an object after the row stopped pointing
75
+ * at it — a duplicated row sharing one photo, a soft-deleted row whose "restore"
76
+ * is expected to bring it back, a draft or a pending approval holding a key.
77
+ *
78
+ * REQUIRED, and the fifth knob for the same reason as the other four. This one
79
+ * used to default to `[]`, which is not a neutral default: it means *reclaim
80
+ * everything immediately*. An adopter wired the mount, got working uploads and
81
+ * shipped; weeks later a store owner restored a product from the recycle bin and
82
+ * the row came back with its photo gone, because the write that replaced the
83
+ * photo had correctly found no probe claiming the old key. Nothing logged
84
+ * anything — the reclaim did exactly what it was told.
85
+ *
86
+ * Pass `[]` to mean it: nothing in this host copies a key, so a replaced object
87
+ * is safe to delete at once. Written out, by a host that has checked, rather than
88
+ * inherited from whoever typed the first mount.
89
+ */
90
+ references: readonly StorageReferenceProbe[];
91
+ /** Where a best-effort reclaim's failures go. Default: no-op, and say so. */
92
+ logger?: StorageLogger;
93
+ /** The derived sizes to cut. Default {@link CATALOG_RENDITIONS}. */
94
+ renditions?: readonly RenditionSpec[];
95
+ /** Top-level key segment. Default `products`. */
96
+ keyPrefix?: string;
97
+ /** pt-BR refusal copy overrides. */
98
+ messages?: Partial<StorageMessages>;
99
+ }
100
+
101
+ /** What the mount reports about itself — the anti-drift surface. */
102
+ export interface StorageLimits {
103
+ maxBytes: number;
104
+ /** Formatted for a message a store owner reads (`8 MB`). */
105
+ maxBytesLabel: string;
106
+ contentTypes: readonly string[];
107
+ driver: string;
108
+ pipeline: string;
109
+ /** Whether this mount cuts crops at all — false on a passthrough pipeline. */
110
+ cutsRenditions: boolean;
111
+ }
112
+
113
+ export interface ApiStorage {
114
+ routes: readonly StorageRoute[];
115
+ limits: StorageLimits;
116
+ /** Store raw bytes (the browser's path, and any host write holding bytes). */
117
+ storeImage(input: { bytes: Uint8Array; contentType: string; scope: string }): Promise<string>;
118
+ /** Store base64 bytes carried inside a write body — an agent's only path. */
119
+ storeInlineImage(image: InlineImage, scope: string): Promise<string>;
120
+ /**
121
+ * Schemas built from THIS mount's own config, for a host's write bodies and tool
122
+ * definitions — `inlineImage`'s base64 ceiling IS `maxBytes`, and `objectKey`'s
123
+ * grammar IS `keyPrefix`. Read them off the mount so neither can advertise
124
+ * something the endpoint does not enforce.
125
+ */
126
+ schemas: {
127
+ inlineImage: ReturnType<typeof inlineImageSchema>;
128
+ /**
129
+ * For the `imageKey` field beside it. `z.string()` there accepts an absolute
130
+ * URL, which `objectUrl` renders verbatim into an `<img src>` — see
131
+ * {@link objectKeySchema}.
132
+ */
133
+ objectKey: ReturnType<typeof objectKeySchema>;
134
+ };
135
+ /** Resolve a stored key for display, through the active driver. */
136
+ urls: {
137
+ objectUrl(key: string | null): string | null;
138
+ imageSources(key: string | null): ImageSources | null;
139
+ versionedObjectUrl(key: string | null, version: string | null): string | null;
140
+ };
141
+ /** Reclaiming bytes nothing points at any more. Never throws. */
142
+ reclaim: {
143
+ deleteIfOrphaned(scope: string, key: string | null | undefined): Promise<void>;
144
+ deleteReplaced(
145
+ scope: string,
146
+ before: readonly (string | null | undefined)[],
147
+ after: readonly (string | null | undefined)[],
148
+ ): Promise<void>;
149
+ discardMinted(scope: string, keys: readonly (string | null | undefined)[]): Promise<void>;
150
+ /** Every object key a stored photo occupies — the key plus its crops. */
151
+ objectKeysFor(key: string): string[];
152
+ };
153
+ }
154
+
155
+ /**
156
+ * A logger that drops everything, and the only default in this file.
157
+ *
158
+ * A reclaim's failures are the one thing here that must not be raised — the write
159
+ * it followed already succeeded — so they can only be reported. A host that
160
+ * passes nothing is choosing not to hear about a bucket that has started refusing
161
+ * deletes; that is a legitimate choice for a test and a bad one in production,
162
+ * and it is stated here rather than hidden behind a `console` call that a server
163
+ * logger would not pick up anyway.
164
+ */
165
+ const SILENT: StorageLogger = { error: () => undefined };
166
+
167
+ export function createApiStorage(config: ApiStorageConfig): ApiStorage {
168
+ const specs = config.renditions ?? CATALOG_RENDITIONS;
169
+ const keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX;
170
+ const messages: StorageMessages = {
171
+ ...defaultStorageMessages({ limit: megabytes(config.maxBytes) }),
172
+ ...config.messages,
173
+ };
174
+ const writeDeps: StoreImageDeps = {
175
+ driver: config.driver,
176
+ pipeline: config.imagePipeline,
177
+ messages,
178
+ maxBytes: config.maxBytes,
179
+ specs,
180
+ keyPrefix,
181
+ };
182
+ const reclaimDeps: ReclaimDeps = {
183
+ driver: config.driver,
184
+ probes: config.references,
185
+ logger: config.logger ?? SILENT,
186
+ unscopedKeys: config.unscopedKeys,
187
+ specs,
188
+ keyPrefix,
189
+ };
190
+ const resolve = (key: string): string => config.driver.publicUrl(key);
191
+
192
+ return {
193
+ routes: storageRoutes(writeDeps),
194
+ limits: {
195
+ maxBytes: config.maxBytes,
196
+ maxBytesLabel: megabytes(config.maxBytes),
197
+ contentTypes: ACCEPTED_CONTENT_TYPES,
198
+ driver: config.driver.name,
199
+ pipeline: config.imagePipeline.name,
200
+ cutsRenditions: config.imagePipeline.cutsRenditions,
201
+ },
202
+ storeImage: (input) => storeImage(writeDeps, input),
203
+ storeInlineImage: (image, scope) => storeInlineImage(writeDeps, image, scope),
204
+ schemas: {
205
+ inlineImage: inlineImageSchema(config.maxBytes),
206
+ objectKey: objectKeySchema(keyPrefix),
207
+ },
208
+ urls: {
209
+ objectUrl: (key) => objectUrl(key, resolve),
210
+ imageSources: (key) => imageSources(key, resolve, { specs, prefix: keyPrefix }),
211
+ versionedObjectUrl: (key, version) => versionedObjectUrl(key, version, resolve),
212
+ },
213
+ reclaim: {
214
+ deleteIfOrphaned: (scope, key) => deleteIfOrphaned(reclaimDeps, scope, key),
215
+ deleteReplaced: (scope, before, after) =>
216
+ deleteReplaced(reclaimDeps, scope, before, after),
217
+ discardMinted: (scope, keys) => discardMinted(reclaimDeps, scope, keys),
218
+ objectKeysFor: (key) => objectKeysFor(key, specs, keyPrefix),
219
+ },
220
+ };
221
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The DRIVER port: the only thing the rest of the package knows about where
3
+ * bytes live.
4
+ *
5
+ * Three operations, and a fourth that is deliberately optional:
6
+ *
7
+ * - `put` — store bytes at a key, world-readable, FROM THE SERVER. The only
8
+ * way anything is ever written; there is no address a driver can
9
+ * hand a browser to PUT at, which is what makes the CORS class of
10
+ * failure described in the root entry unreachable rather than
11
+ * merely avoided.
12
+ * - `delete` — remove an object. Deleting one that is already gone is SUCCESS:
13
+ * the caller is reconciling "nothing references this key any
14
+ * more", and an absent object satisfies that.
15
+ * - `publicUrl` — a stored key as something a browser can load. Synchronous,
16
+ * because it is called during render.
17
+ * - `read` — OPTIONAL, and its presence is what tells the serve route which
18
+ * of its two behaviours applies. A driver that keeps objects on
19
+ * the app server's own disk implements it and the route streams
20
+ * bytes; a bucket-backed driver does not, and the route redirects
21
+ * to `publicUrl` instead. That used to be an environment flag
22
+ * read inside the route, which is the same fact stated somewhere
23
+ * it could disagree with the driver actually in use.
24
+ *
25
+ * A second vendor is a second factory satisfying this interface plus a config
26
+ * entry naming it — no host code, and nothing in the upload path changes.
27
+ */
28
+
29
+ /** An object read back from a driver that holds its own bytes. */
30
+ export interface StoredObject {
31
+ bytes: Uint8Array;
32
+ contentType: string;
33
+ }
34
+
35
+ export interface StorageDriver {
36
+ /** Which driver this is, for log lines and for the mount's own reporting. */
37
+ readonly name: string;
38
+ put(key: string, bytes: Uint8Array, contentType: string): Promise<void>;
39
+ delete(key: string): Promise<void>;
40
+ publicUrl(key: string): string;
41
+ /** Present only on drivers whose objects the app server can serve itself. */
42
+ read?(key: string): Promise<StoredObject | null>;
43
+ }
@@ -0,0 +1,104 @@
1
+ import { mkdir, readFile, rm, rmdir, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { CONTENT_TYPE_BY_EXTENSION } from '../../content-types';
5
+ import type { StorageDriver, StoredObject } from '../driver';
6
+
7
+ /**
8
+ * Objects on the app server's OWN DISK — the development driver, and the one
9
+ * that needs no account, no bucket and no credentials.
10
+ *
11
+ * `root` is REQUIRED and has no default on purpose. `<cwd>/.uploads` is a fine
12
+ * development value and a silent disaster in production: a container's disk is
13
+ * ephemeral, so an app that fell back to it would report every upload as a
14
+ * success and lose the objects at the next deploy — with nothing in any log to
15
+ * say so. A host that wants the development default writes it in its own config,
16
+ * where the choice is visible and reviewable.
17
+ *
18
+ * `publicPathPrefix` is where the serve route is mounted, so a stored key can be
19
+ * turned into a URL on this same origin. One definition of both, so a deployment
20
+ * cannot write objects to one place and serve them from another.
21
+ */
22
+ export interface LocalDiskDriverConfig {
23
+ /** Absolute path of the directory objects live under. */
24
+ root: string;
25
+ /** Path the serve route is mounted at, e.g. `/api/uploads/local`. */
26
+ publicPathPrefix: string;
27
+ }
28
+
29
+ /** A key segment safe to use as a path component. */
30
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
31
+
32
+ /**
33
+ * Absolute path for a stored object key under `root`.
34
+ *
35
+ * Keys reaching here are server-minted, so the segment check is a backstop
36
+ * rather than the validation of an untrusted key — the serve route validates its
37
+ * caller-supplied path itself, in more detail, before it ever builds one. It
38
+ * throws rather than returning null because there is no legitimate caller that
39
+ * can produce a traversal segment: one here would mean a key was assembled
40
+ * somewhere it should not have been.
41
+ */
42
+ export function localObjectPath(root: string, key: string): string {
43
+ const segments = key.split('/');
44
+ if (!segments.every((segment) => SAFE_SEGMENT.test(segment))) {
45
+ throw new Error(`Refusing to resolve an unsafe object key: ${key}`);
46
+ }
47
+ return path.join(root, ...segments);
48
+ }
49
+
50
+ /** `full.webp` → `image/webp`; an unknown extension → a generic type. */
51
+ function contentTypeOfPath(filePath: string): string {
52
+ const extension = path.extname(filePath).replace('.', '').toLowerCase();
53
+ return CONTENT_TYPE_BY_EXTENSION[extension] ?? 'application/octet-stream';
54
+ }
55
+
56
+ /**
57
+ * Remove the object at `filePath`, then prune the directory it was the last
58
+ * member of.
59
+ *
60
+ * A photo stored with its crops is six files in a directory of its own, so
61
+ * reclaiming one leaves that directory behind, empty, once the last file goes.
62
+ * A bucket has no such thing — a key's slashes are just characters — so the
63
+ * leftover is purely an artifact of this driver, and pruning it keeps the root a
64
+ * truthful picture of what is stored. `rmdir` on a directory that still holds a
65
+ * sibling fails, which IS the check: no listing, and no race between two
66
+ * concurrent reclaims.
67
+ */
68
+ async function removeAndPrune(root: string, filePath: string): Promise<void> {
69
+ await rm(filePath, { force: true });
70
+ const parent = path.dirname(filePath);
71
+ if (path.resolve(parent) === path.resolve(root)) return;
72
+ try {
73
+ await rmdir(parent);
74
+ } catch {
75
+ // Not empty, or already gone. Either way there is nothing to clean up.
76
+ }
77
+ }
78
+
79
+ export function createLocalDiskDriver(config: LocalDiskDriverConfig): StorageDriver {
80
+ const prefix = config.publicPathPrefix.replace(/\/$/, '');
81
+ return {
82
+ name: 'local-disk',
83
+ put: async (key, bytes) => {
84
+ const filePath = localObjectPath(config.root, key);
85
+ await mkdir(path.dirname(filePath), { recursive: true });
86
+ await writeFile(filePath, bytes);
87
+ },
88
+ delete: async (key) => {
89
+ await removeAndPrune(config.root, localObjectPath(config.root, key));
90
+ },
91
+ publicUrl: (key) => `${prefix}/${key}`,
92
+ read: async (key): Promise<StoredObject | null> => {
93
+ const filePath = localObjectPath(config.root, key);
94
+ try {
95
+ // `stat` first, so a directory whose name happens to match a key reads
96
+ // as absent rather than as an unreadable object.
97
+ if (!(await stat(filePath)).isFile()) return null;
98
+ return { bytes: new Uint8Array(await readFile(filePath)), contentType: contentTypeOfPath(filePath) };
99
+ } catch {
100
+ return null;
101
+ }
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `@12-apps/storage/server` — the backend half.
3
+ *
4
+ * One factory, {@link createApiStorage}, plus the ports it is configured with and
5
+ * the implementations the package ships for them. Nothing here reads
6
+ * `process.env`: every deployment-shaped decision is an argument, which is what
7
+ * makes the same mount correct in a test, on a laptop and in production.
8
+ *
9
+ * The S3-compatible driver lives behind `@12-apps/storage/s3` instead of here, so
10
+ * a host on local disk never resolves the AWS SDK — the same arrangement `hono`
11
+ * has.
12
+ */
13
+
14
+ export {
15
+ createApiStorage,
16
+ type ApiStorage,
17
+ type ApiStorageConfig,
18
+ type StorageLimits,
19
+ } from './create-api-storage';
20
+
21
+ export type { StorageDriver, StoredObject } from './driver';
22
+
23
+ export {
24
+ createLocalDiskDriver,
25
+ localObjectPath,
26
+ type LocalDiskDriverConfig,
27
+ } from './drivers/local-disk';
28
+
29
+ export {
30
+ assertRemoteUrlConfig,
31
+ remoteObjectUrl,
32
+ type RemoteObjectUrlConfig,
33
+ } from './remote-url';
34
+
35
+ export { passthroughImagePipeline } from './pipeline/passthrough';
36
+ export { createSharpImagePipeline, type SharpPipelineConfig } from './pipeline/sharp';
37
+ export type {
38
+ ImagePipeline,
39
+ ProcessResult,
40
+ ProcessedImage,
41
+ } from './pipeline/port';
42
+ export type {
43
+ Rgba,
44
+ SharpImage,
45
+ SharpMetadata,
46
+ SharpModule,
47
+ SharpResizeOptions,
48
+ } from './pipeline/sharp-module';
49
+
50
+ export { readBodyCapped } from './read-body';
51
+
52
+ export type { StorageLogger, StorageReferenceProbe } from './reclaim';
53
+
54
+ export { STORAGE_PATHS } from '../paths';
55
+
56
+ export {
57
+ type StorageActor,
58
+ type StorageRoute,
59
+ type StorageRouteContext,
60
+ type StorageRouteResponse,
61
+ } from './routes';
@@ -0,0 +1,28 @@
1
+ import { verifyImageBytes } from '../../payload';
2
+ import type { ImagePipeline, ProcessResult } from './port';
3
+
4
+ /**
5
+ * Store the bytes as they arrived: no downscale, no re-encode, no crops.
6
+ *
7
+ * The pipeline for a host that will not take a native dependency. It is not a
8
+ * degraded mode so much as the behaviour every catalog image had before crops
9
+ * existed: one object, drawn `contain`, and a key whose shape says truthfully
10
+ * that there are no crops to ask for.
11
+ *
12
+ * What it still does is verify: the magic-number check is what stops an upload
13
+ * surface being a way to park arbitrary content at a world-readable URL on the
14
+ * store's own domain, and that is not the resizer's job to provide.
15
+ */
16
+ export function passthroughImagePipeline(): ImagePipeline {
17
+ return {
18
+ name: 'passthrough',
19
+ cutsRenditions: false,
20
+ process: async (bytes, declaredContentType): Promise<ProcessResult> => {
21
+ const problem = verifyImageBytes(bytes, declaredContentType);
22
+ return problem
23
+ ? { ok: false, problem }
24
+ : { ok: true, image: { bytes, contentType: declaredContentType } };
25
+ },
26
+ cut: async () => [],
27
+ };
28
+ }
@@ -0,0 +1,59 @@
1
+ import type { RenditionOutput, RenditionSpec } from '../../renditions';
2
+ import type { StorageProblem } from '../../problems';
3
+
4
+ /**
5
+ * The IMAGE PIPELINE port — how bytes are downscaled, re-encoded and cropped.
6
+ *
7
+ * It is a port rather than a dependency because image processing means a NATIVE
8
+ * module, and a native module is build surface every consumer of this package
9
+ * would pay for on every deploy whether or not it wants renditions. A host that
10
+ * has `sharp` passes `createSharpImagePipeline({ sharp })` and gets the whole
11
+ * behaviour; a host that does not passes {@link passthroughImagePipeline} and
12
+ * gets uploads with no crops, which is exactly what every catalog image was
13
+ * before crops existed. Neither choice is a default: `imagePipeline` is required
14
+ * config, because "which one am I running?" is not a question a host should have
15
+ * to discover from the shape of a key in production.
16
+ *
17
+ * The two operations are independent on purpose, and the writer runs them
18
+ * together: cutting crops from the ORIGINAL bytes rather than from the
19
+ * downscaled object avoids resampling a 1280px intermediate down to a 320px
20
+ * card, and on a large photo the two are the whole cost of the request.
21
+ */
22
+
23
+ /** What the pipeline decided the uncropped object should be. */
24
+ export interface ProcessedImage {
25
+ bytes: Uint8Array;
26
+ /** The type the object is STORED as — the extension is minted from it. */
27
+ contentType: string;
28
+ }
29
+
30
+ export type ProcessResult =
31
+ | { ok: true; image: ProcessedImage }
32
+ | { ok: false; problem: StorageProblem };
33
+
34
+ export interface ImagePipeline {
35
+ /** A name for the mount's own reporting. */
36
+ readonly name: string;
37
+ /**
38
+ * Whether this pipeline cuts crops at all. Stated rather than inferred: a
39
+ * consumer deciding what to draw needs to know before the first upload, and
40
+ * "call it and see" is not an answer a host can put in a health check.
41
+ */
42
+ readonly cutsRenditions: boolean;
43
+ /**
44
+ * The uncropped object to store. NEVER throws: a decode failure is reported as
45
+ * a problem, because the caller is a write that needs to answer with a status
46
+ * rather than a stack trace.
47
+ */
48
+ process(bytes: Uint8Array, declaredContentType: string): Promise<ProcessResult>;
49
+ /**
50
+ * Cut every requested size, or return an EMPTY list.
51
+ *
52
+ * Empty is a first-class answer and the writer reads it as "this photo has no
53
+ * crops", which is what routes it to the flat key shape. Never throws, and
54
+ * never returns a PARTIAL set: a browser handed a key whose shape promises
55
+ * five objects would draw the broken-image glyph for the ones that are missing,
56
+ * so one size failing drops the four that worked.
57
+ */
58
+ cut(bytes: Uint8Array, specs: readonly RenditionSpec[]): Promise<RenditionOutput[]>;
59
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The slice of `sharp`'s API this package uses, described STRUCTURALLY.
3
+ *
4
+ * The pipeline takes the module as an argument instead of importing it, so
5
+ * `sharp` is neither a dependency nor a peer of this package: a host that wants
6
+ * renditions passes the module it already has, and every other consumer installs
7
+ * nothing. Describing the surface here rather than importing sharp's types keeps
8
+ * that true for the type-checker too — a package that imported the types would
9
+ * make `@types/sharp` a hard requirement of compiling it.
10
+ *
11
+ * `sharp`'s own `Sharp` interface is wider than this in every direction, so the
12
+ * real module satisfies it by structural width subtyping; nothing here needs to
13
+ * be kept in step with sharp's version except when this file starts calling
14
+ * something new.
15
+ */
16
+
17
+ /** An RGBA colour, in the shape sharp's `background` option takes. */
18
+ export interface Rgba {
19
+ r: number;
20
+ g: number;
21
+ b: number;
22
+ alpha: number;
23
+ }
24
+
25
+ export interface SharpMetadata {
26
+ width?: number | undefined;
27
+ height?: number | undefined;
28
+ /** Frames in an animation. 1 (or absent) for a still. */
29
+ pages?: number | undefined;
30
+ }
31
+
32
+ export interface SharpResizeOptions {
33
+ width?: number | undefined;
34
+ height?: number | undefined;
35
+ fit?: 'inside' | 'contain' | undefined;
36
+ background?: Rgba | undefined;
37
+ withoutEnlargement?: boolean | undefined;
38
+ }
39
+
40
+ export interface SharpImage {
41
+ metadata(): Promise<SharpMetadata>;
42
+ resize(options: SharpResizeOptions): SharpImage;
43
+ extract(region: { left: number; top: number; width: number; height: number }): SharpImage;
44
+ ensureAlpha(): SharpImage;
45
+ raw(): SharpImage;
46
+ trim(options: { background: Rgba; threshold: number }): SharpImage;
47
+ png(): SharpImage;
48
+ webp(options: { quality: number }): SharpImage;
49
+ toFormat(format: string): SharpImage;
50
+ toBuffer(): Promise<Uint8Array>;
51
+ }
52
+
53
+ /** `sharp(bytes, { animated })`. */
54
+ export type SharpModule = (
55
+ input: Uint8Array,
56
+ options?: { animated?: boolean },
57
+ ) => SharpImage;