@nixxie-cms/fields-computed-image 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.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nixxie International DMCC
4
+ Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
5
+ (this software is derived from the KeystoneJS project, https://keystonejs.com)
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @nixxie-cms/fields-computed-image
2
+
3
+ An image upload field for Nixxie CMS that computes width/height, generates a blur placeholder
4
+ and a thumbnail, and stores files through the pluggable storage service
5
+ (`context.services.storage`).
6
+
7
+ ## Requirements
8
+
9
+ - A configured **storage service** (`context.services.storage`, e.g. from
10
+ `@nixxie-cms/storage` — local, S3, GCS or Azure). Mutations fail with a clear error when it is
11
+ missing.
12
+ - The **`sharp`** peer dependency (lazy-loaded; only needed at runtime when an image is
13
+ uploaded):
14
+
15
+ ```sh
16
+ pnpm add sharp
17
+ ```
18
+
19
+ ## Schema
20
+
21
+ ```ts
22
+ import { computedImage } from '@nixxie-cms/fields-computed-image'
23
+
24
+ fields: {
25
+ cover: computedImage({
26
+ storage: { prefix: 'covers/' }, // key prefix, default 'images/'
27
+ thumbnail: { width: 480 }, // or false to disable, default { width: 320 }
28
+ blur: true, // inline blur placeholder, default true
29
+ }),
30
+ }
31
+ ```
32
+
33
+ ## Uploading from a client
34
+
35
+ The field's GraphQL input is plain JSON — `{ base64, filename? }` (data URLs are accepted), or
36
+ `null` to clear the field. Decoded images are capped at ~50MB.
37
+
38
+ ```ts
39
+ // browser
40
+ const file = fileInput.files[0]
41
+ const base64 = await new Promise<string>(resolve => {
42
+ const reader = new FileReader()
43
+ reader.onload = () => resolve(reader.result as string) // data:image/…;base64,…
44
+ reader.readAsDataURL(file)
45
+ })
46
+
47
+ await fetch('/api/graphql', {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({
51
+ query: `mutation ($id: ID!, $cover: JSON) {
52
+ updatePost(where: { id: $id }, data: { cover: $cover }) {
53
+ cover { url thumbnailUrl width height blurDataUrl }
54
+ }
55
+ }`,
56
+ variables: { id, cover: { base64, filename: file.name } },
57
+ }),
58
+ })
59
+ ```
60
+
61
+ > Plain JSON was chosen deliberately over a multipart `Upload` scalar: the raw GraphQL input
62
+ > resolver does not receive the request context, so all processing (sharp + storage) happens in
63
+ > a field-level `resolveInput` hook, which does. Base64 adds ~33% transfer overhead — fine for
64
+ > CMS-sized images under the 50MB cap.
65
+
66
+ ## What gets stored
67
+
68
+ Two objects go to storage (both `public: true`):
69
+
70
+ - the original: `<prefix><randomId>.<ext>` (e.g. `images/aB3xY9….jpg`)
71
+ - the thumbnail: `<prefix><randomId>.thumb.webp` (unless `thumbnail: false`)
72
+
73
+ The database column (JSON) holds:
74
+
75
+ ```jsonc
76
+ {
77
+ "key": "images/aB3xY9….jpg",
78
+ "url": "https://…/images/aB3xY9….jpg",
79
+ "thumbnailKey": "images/aB3xY9….thumb.webp", // null when disabled
80
+ "thumbnailUrl": "https://…", // null when disabled
81
+ "width": 2048,
82
+ "height": 1365,
83
+ "filesize": 482133, // bytes (original)
84
+ "format": "jpeg", // as detected by sharp
85
+ "blurDataUrl": "data:image/webp;base64,…", // 16px webp, null when blur: false
86
+ "filename": "holiday.jpg" // client-supplied, null when omitted
87
+ }
88
+ ```
89
+
90
+ The GraphQL output is a typed `ComputedImage` object exposing those same fields.
91
+
92
+ ## Cleanup
93
+
94
+ An `afterOperation` hook removes both objects from storage when the item is deleted, and removes
95
+ the previous objects when the image is replaced or cleared on update. Cleanup failures are
96
+ logged with `console.error` and never thrown (the database write has already succeeded).
97
+
98
+ ## Notes
99
+
100
+ - The Admin UI renders this field with the core JSON views (no bespoke upload UI is shipped).
101
+ - Type exports: `ComputedImageData` (stored shape), `ComputedImageInput` (mutation input shape),
102
+ `ComputedImageFieldConfig`.
@@ -0,0 +1,53 @@
1
+ import type { BaseFieldTypeInfo, BaseCollectionTypeInfo, CommonFieldConfig, FieldTypeFunc } from '@nixxie-cms/core/types';
2
+ /** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
3
+ export type ComputedImageInput = {
4
+ base64: string;
5
+ filename?: string;
6
+ } | null;
7
+ /** The JSON value stored in the database (and returned from the output type). */
8
+ export type ComputedImageData = {
9
+ /** Storage key of the original image. */
10
+ key: string;
11
+ /** URL of the original image. */
12
+ url: string;
13
+ /** Storage key of the generated thumbnail (null when thumbnails are disabled). */
14
+ thumbnailKey: string | null;
15
+ /** URL of the generated thumbnail. */
16
+ thumbnailUrl: string | null;
17
+ width: number;
18
+ height: number;
19
+ /** Original file size in bytes. */
20
+ filesize: number;
21
+ /** Image format as detected by sharp, e.g. 'jpeg', 'png', 'webp'. */
22
+ format: string;
23
+ /** Tiny inline blur placeholder (`data:image/webp;base64,…`), null when disabled. */
24
+ blurDataUrl: string | null;
25
+ /** Client-supplied filename, if any. */
26
+ filename: string | null;
27
+ };
28
+ export type ComputedImageFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = CommonFieldConfig<CollectionTypeInfo, BaseFieldTypeInfo> & {
29
+ storage?: {
30
+ /** Key prefix for stored objects. Default: 'images/' */
31
+ prefix?: string;
32
+ };
33
+ /** Thumbnail generation options, or `false` to disable. Default: { width: 320 } */
34
+ thumbnail?: {
35
+ width?: number;
36
+ } | false;
37
+ /** Generate a tiny inline blur placeholder. Default: true */
38
+ blur?: boolean;
39
+ db?: {
40
+ map?: string;
41
+ };
42
+ };
43
+ /**
44
+ * An image upload field that computes width/height, generates a blur placeholder and a
45
+ * thumbnail, and stores files through `context.services.storage`.
46
+ *
47
+ * The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
48
+ * processing happens in a field-level `resolveInput` hook (which receives the request context,
49
+ * unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
50
+ * a configured storage service.
51
+ */
52
+ export declare function computedImage<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: ComputedImageFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
53
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,iBAAiB,EACjB,sBAAsB,EACtB,iBAAiB,EACjB,aAAa,EACd,MAAM,wBAAwB,CAAA;AAO/B,+FAA+F;AAC/F,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB,GAAG,IAAI,CAAA;AAER,iFAAiF;AACjF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,yCAAyC;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,iCAAiC;IACjC,GAAG,EAAE,MAAM,CAAA;IACX,kFAAkF;IAClF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,sCAAsC;IACtC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,mCAAmC;IACnC,QAAQ,EAAE,MAAM,CAAA;IAChB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAA;IACd,qFAAqF;IACrF,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,wCAAwC;IACxC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,wBAAwB,CAAC,kBAAkB,SAAS,sBAAsB,IACpF,iBAAiB,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,GAAG;IACzD,OAAO,CAAC,EAAE;QACR,wDAAwD;QACxD,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;IACD,mFAAmF;IACnF,SAAS,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IACtC,6DAA6D;IAC7D,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,EAAE,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACtB,CAAA;AA0DH;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,kBAAkB,SAAS,sBAAsB,EAC7E,MAAM,GAAE,wBAAwB,CAAC,kBAAkB,CAAM,GACxD,aAAa,CAAC,kBAAkB,CAAC,CAsLnC"}
@@ -0,0 +1,2 @@
1
+ export * from "./declarations/src/index.js";
2
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtY29tcHV0ZWQtaW1hZ2UuY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var core = require('@nixxie-cms/core');
6
+ var types = require('@nixxie-cms/core/types');
7
+ var node_crypto = require('node:crypto');
8
+
9
+ const PKG = '[@nixxie-cms/fields-computed-image]';
10
+ const MAX_BYTES = 50 * 1024 * 1024; // ~50MB decoded
11
+
12
+ /** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
13
+
14
+ /** The JSON value stored in the database (and returned from the output type). */
15
+
16
+ const CONTENT_TYPES = {
17
+ jpeg: 'image/jpeg',
18
+ png: 'image/png',
19
+ webp: 'image/webp',
20
+ gif: 'image/gif',
21
+ avif: 'image/avif',
22
+ tiff: 'image/tiff',
23
+ svg: 'image/svg+xml',
24
+ heif: 'image/heif'
25
+ };
26
+ const outputType = core.g.object()({
27
+ name: 'ComputedImage',
28
+ fields: {
29
+ key: core.g.field({
30
+ type: core.g.nonNull(core.g.String)
31
+ }),
32
+ url: core.g.field({
33
+ type: core.g.nonNull(core.g.String)
34
+ }),
35
+ thumbnailKey: core.g.field({
36
+ type: core.g.String
37
+ }),
38
+ thumbnailUrl: core.g.field({
39
+ type: core.g.String
40
+ }),
41
+ width: core.g.field({
42
+ type: core.g.nonNull(core.g.Int)
43
+ }),
44
+ height: core.g.field({
45
+ type: core.g.nonNull(core.g.Int)
46
+ }),
47
+ filesize: core.g.field({
48
+ type: core.g.nonNull(core.g.Int)
49
+ }),
50
+ format: core.g.field({
51
+ type: core.g.nonNull(core.g.String)
52
+ }),
53
+ blurDataUrl: core.g.field({
54
+ type: core.g.String
55
+ }),
56
+ filename: core.g.field({
57
+ type: core.g.String
58
+ })
59
+ }
60
+ });
61
+ async function loadSharp() {
62
+ try {
63
+ var _mod$default;
64
+ const mod = await import('sharp');
65
+ return (_mod$default = mod.default) !== null && _mod$default !== void 0 ? _mod$default : mod;
66
+ } catch {
67
+ throw new Error(`${PKG} sharp is required — install it with: pnpm add sharp`);
68
+ }
69
+ }
70
+
71
+ /** Decode a base64 string (optionally a data URL), enforcing the decoded size cap. */
72
+ function decodeBase64(input, fieldKey) {
73
+ let base64 = input.trim();
74
+ const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(base64);
75
+ if (dataUrlMatch) base64 = dataUrlMatch[1];
76
+ base64 = base64.replace(/\s+/g, '');
77
+ // Pre-check before allocating: decoded size is ~3/4 of the base64 length.
78
+ if (base64.length * 0.75 > MAX_BYTES) {
79
+ throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
80
+ }
81
+ const buffer = Buffer.from(base64, 'base64');
82
+ if (buffer.length === 0) {
83
+ throw new Error(`${PKG} ${fieldKey}: base64 could not be decoded to any data`);
84
+ }
85
+ if (buffer.length > MAX_BYTES) {
86
+ throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
87
+ }
88
+ return buffer;
89
+ }
90
+
91
+ /**
92
+ * An image upload field that computes width/height, generates a blur placeholder and a
93
+ * thumbnail, and stores files through `context.services.storage`.
94
+ *
95
+ * The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
96
+ * processing happens in a field-level `resolveInput` hook (which receives the request context,
97
+ * unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
98
+ * a configured storage service.
99
+ */
100
+ function computedImage(config = {}) {
101
+ var _config$storage$prefi, _config$storage, _config$thumbnail$wid, _config$thumbnail, _config$blur;
102
+ const prefix = (_config$storage$prefi = (_config$storage = config.storage) === null || _config$storage === void 0 ? void 0 : _config$storage.prefix) !== null && _config$storage$prefi !== void 0 ? _config$storage$prefi : 'images/';
103
+ const thumbnailWidth = config.thumbnail === false ? null : (_config$thumbnail$wid = (_config$thumbnail = config.thumbnail) === null || _config$thumbnail === void 0 ? void 0 : _config$thumbnail.width) !== null && _config$thumbnail$wid !== void 0 ? _config$thumbnail$wid : 320;
104
+ const blurEnabled = (_config$blur = config.blur) !== null && _config$blur !== void 0 ? _config$blur : true;
105
+ return meta => {
106
+ var _config$hooks, _config$hooks2, _config$db;
107
+ if (config.isIndexed === 'unique') {
108
+ throw Error("isIndexed: 'unique' is not a supported option for field type computedImage");
109
+ }
110
+
111
+ /** Remove a stored image (+thumbnail); logged (not thrown) since the DB write already succeeded. */
112
+ async function removeStored(context, stored) {
113
+ var _context$services;
114
+ if (!stored || typeof stored !== 'object') return;
115
+ const storage = context === null || context === void 0 || (_context$services = context.services) === null || _context$services === void 0 ? void 0 : _context$services.storage;
116
+ if (!storage) {
117
+ if (stored.key) {
118
+ console.error(`${PKG} Cannot clean up "${stored.key}": context.services.storage is not configured`);
119
+ }
120
+ return;
121
+ }
122
+ for (const key of [stored.key, stored.thumbnailKey]) {
123
+ if (typeof key !== 'string' || key.length === 0) continue;
124
+ try {
125
+ await storage.delete(key);
126
+ } catch (err) {
127
+ console.error(`${PKG} Failed to delete "${key}" from storage:`, err);
128
+ }
129
+ }
130
+ }
131
+
132
+ /** Decode, measure, derive (thumbnail + blur), store, and return the value to persist. */
133
+ async function processInput(args) {
134
+ var _context$services2, _CONTENT_TYPES$format;
135
+ const {
136
+ resolvedFieldData: value,
137
+ fieldKey,
138
+ context
139
+ } = args;
140
+ if (value === undefined) return undefined;
141
+ if (value === null) return null;
142
+ if (typeof value !== 'object' || Array.isArray(value) || typeof value.base64 !== 'string') {
143
+ throw new Error(`${PKG} ${fieldKey}: expected { base64: string, filename?: string } or null`);
144
+ }
145
+ const input = value;
146
+ const filename = typeof input.filename === 'string' && input.filename.length > 0 ? input.filename : null;
147
+ const storage = context === null || context === void 0 || (_context$services2 = context.services) === null || _context$services2 === void 0 ? void 0 : _context$services2.storage;
148
+ if (!storage) {
149
+ throw new Error(`${PKG} ${fieldKey}: context.services.storage is not configured — a storage service is required`);
150
+ }
151
+ const buffer = decodeBase64(input.base64, fieldKey);
152
+ const sharp = await loadSharp();
153
+ let metadata;
154
+ try {
155
+ metadata = await sharp(buffer).metadata();
156
+ } catch (err) {
157
+ var _message;
158
+ throw new Error(`${PKG} ${fieldKey}: could not read image (unsupported or corrupt): ${(_message = err === null || err === void 0 ? void 0 : err.message) !== null && _message !== void 0 ? _message : err}`);
159
+ }
160
+ const {
161
+ width,
162
+ height,
163
+ format
164
+ } = metadata !== null && metadata !== void 0 ? metadata : {};
165
+ if (typeof width !== 'number' || typeof height !== 'number' || typeof format !== 'string') {
166
+ throw new Error(`${PKG} ${fieldKey}: could not determine image dimensions/format`);
167
+ }
168
+ const id = node_crypto.randomBytes(12).toString('base64url');
169
+ const ext = format === 'jpeg' ? 'jpg' : format;
170
+ const key = `${prefix}${id}.${ext}`;
171
+ const contentType = (_CONTENT_TYPES$format = CONTENT_TYPES[format]) !== null && _CONTENT_TYPES$format !== void 0 ? _CONTENT_TYPES$format : `image/${format}`;
172
+ const stored = await storage.put(key, buffer, {
173
+ contentType,
174
+ public: true
175
+ });
176
+ let thumbnailKey = null;
177
+ let thumbnailUrl = null;
178
+ let blurDataUrl = null;
179
+ try {
180
+ if (thumbnailWidth !== null) {
181
+ const thumbBuffer = await sharp(buffer).resize({
182
+ width: thumbnailWidth,
183
+ withoutEnlargement: true
184
+ }).webp().toBuffer();
185
+ thumbnailKey = `${prefix}${id}.thumb.webp`;
186
+ const storedThumb = await storage.put(thumbnailKey, thumbBuffer, {
187
+ contentType: 'image/webp',
188
+ public: true
189
+ });
190
+ thumbnailUrl = storedThumb.url;
191
+ }
192
+ if (blurEnabled) {
193
+ const blurBuffer = await sharp(buffer).resize(16).webp({
194
+ quality: 40
195
+ }).toBuffer();
196
+ blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`;
197
+ }
198
+ } catch (err) {
199
+ // Don't leave orphaned objects behind when derivative generation fails mid-way.
200
+ await removeStored(context, {
201
+ key,
202
+ thumbnailKey
203
+ });
204
+ throw err;
205
+ }
206
+ return {
207
+ key,
208
+ url: stored.url,
209
+ thumbnailKey,
210
+ thumbnailUrl,
211
+ width,
212
+ height,
213
+ filesize: buffer.length,
214
+ format,
215
+ blurDataUrl,
216
+ filename
217
+ };
218
+ }
219
+
220
+ // Compose our processing/cleanup with any hooks the user configured: our resolveInput runs
221
+ // first (turning the raw { base64 } input into the stored JSON), then the user's; the
222
+ // user's afterOperation runs first, then our storage cleanup (mirroring @nixxie-cms/cloudinary).
223
+ const userResolveInput = (_config$hooks = config.hooks) === null || _config$hooks === void 0 ? void 0 : _config$hooks.resolveInput;
224
+ const userAfterOperation = (_config$hooks2 = config.hooks) === null || _config$hooks2 === void 0 ? void 0 : _config$hooks2.afterOperation;
225
+ const hooks = {
226
+ ...config.hooks,
227
+ resolveInput: async args => {
228
+ const processed = await processInput(args);
229
+ const userHook = typeof userResolveInput === 'function' ? userResolveInput : userResolveInput === null || userResolveInput === void 0 ? void 0 : userResolveInput[args.operation];
230
+ if (!userHook) return processed;
231
+ return userHook({
232
+ ...args,
233
+ resolvedFieldData: processed,
234
+ resolvedData: {
235
+ ...args.resolvedData,
236
+ [args.fieldKey]: processed
237
+ }
238
+ });
239
+ },
240
+ afterOperation: async args => {
241
+ const userHook = typeof userAfterOperation === 'function' ? userAfterOperation : userAfterOperation === null || userAfterOperation === void 0 ? void 0 : userAfterOperation[args.operation];
242
+ await (userHook === null || userHook === void 0 ? void 0 : userHook(args));
243
+ if (args.operation === 'delete') {
244
+ await removeStored(args.context, args.originalItemField);
245
+ } else if (args.operation === 'update') {
246
+ const previous = args.originalItemField;
247
+ const next = args.itemField;
248
+ if (previous !== null && previous !== void 0 && previous.key && previous.key !== (next === null || next === void 0 ? void 0 : next.key)) {
249
+ await removeStored(args.context, previous);
250
+ }
251
+ }
252
+ }
253
+ };
254
+ const inputArg = core.g.arg({
255
+ type: core.g.JSON
256
+ });
257
+ return types.fieldType({
258
+ kind: 'scalar',
259
+ mode: 'optional',
260
+ scalar: 'Json',
261
+ map: (_config$db = config.db) === null || _config$db === void 0 ? void 0 : _config$db.map
262
+ })({
263
+ ...config,
264
+ hooks,
265
+ __nxTelemetryFieldTypeName: '@nixxie-cms/fields-computed-image',
266
+ input: {
267
+ // The raw GraphQL input resolver does not receive context, so the value passes through
268
+ // here untouched; all storage work happens in the field-level resolveInput hook above.
269
+ create: {
270
+ arg: inputArg
271
+ },
272
+ update: {
273
+ arg: inputArg
274
+ }
275
+ },
276
+ output: core.g.field({
277
+ type: outputType,
278
+ resolve({
279
+ value
280
+ }) {
281
+ if (value === null || typeof value !== 'object') return null;
282
+ const val = value;
283
+ if (typeof val.key !== 'string') return null;
284
+ return {
285
+ thumbnailKey: null,
286
+ thumbnailUrl: null,
287
+ blurDataUrl: null,
288
+ filename: null,
289
+ ...val
290
+ };
291
+ }
292
+ }),
293
+ views: '@nixxie-cms/core/fields/types/json/views'
294
+ });
295
+ };
296
+ }
297
+
298
+ exports.computedImage = computedImage;
@@ -0,0 +1,294 @@
1
+ import { g } from '@nixxie-cms/core';
2
+ import { fieldType } from '@nixxie-cms/core/types';
3
+ import { randomBytes } from 'node:crypto';
4
+
5
+ const PKG = '[@nixxie-cms/fields-computed-image]';
6
+ const MAX_BYTES = 50 * 1024 * 1024; // ~50MB decoded
7
+
8
+ /** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
9
+
10
+ /** The JSON value stored in the database (and returned from the output type). */
11
+
12
+ const CONTENT_TYPES = {
13
+ jpeg: 'image/jpeg',
14
+ png: 'image/png',
15
+ webp: 'image/webp',
16
+ gif: 'image/gif',
17
+ avif: 'image/avif',
18
+ tiff: 'image/tiff',
19
+ svg: 'image/svg+xml',
20
+ heif: 'image/heif'
21
+ };
22
+ const outputType = g.object()({
23
+ name: 'ComputedImage',
24
+ fields: {
25
+ key: g.field({
26
+ type: g.nonNull(g.String)
27
+ }),
28
+ url: g.field({
29
+ type: g.nonNull(g.String)
30
+ }),
31
+ thumbnailKey: g.field({
32
+ type: g.String
33
+ }),
34
+ thumbnailUrl: g.field({
35
+ type: g.String
36
+ }),
37
+ width: g.field({
38
+ type: g.nonNull(g.Int)
39
+ }),
40
+ height: g.field({
41
+ type: g.nonNull(g.Int)
42
+ }),
43
+ filesize: g.field({
44
+ type: g.nonNull(g.Int)
45
+ }),
46
+ format: g.field({
47
+ type: g.nonNull(g.String)
48
+ }),
49
+ blurDataUrl: g.field({
50
+ type: g.String
51
+ }),
52
+ filename: g.field({
53
+ type: g.String
54
+ })
55
+ }
56
+ });
57
+ async function loadSharp() {
58
+ try {
59
+ var _mod$default;
60
+ const mod = await import('sharp');
61
+ return (_mod$default = mod.default) !== null && _mod$default !== void 0 ? _mod$default : mod;
62
+ } catch {
63
+ throw new Error(`${PKG} sharp is required — install it with: pnpm add sharp`);
64
+ }
65
+ }
66
+
67
+ /** Decode a base64 string (optionally a data URL), enforcing the decoded size cap. */
68
+ function decodeBase64(input, fieldKey) {
69
+ let base64 = input.trim();
70
+ const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(base64);
71
+ if (dataUrlMatch) base64 = dataUrlMatch[1];
72
+ base64 = base64.replace(/\s+/g, '');
73
+ // Pre-check before allocating: decoded size is ~3/4 of the base64 length.
74
+ if (base64.length * 0.75 > MAX_BYTES) {
75
+ throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
76
+ }
77
+ const buffer = Buffer.from(base64, 'base64');
78
+ if (buffer.length === 0) {
79
+ throw new Error(`${PKG} ${fieldKey}: base64 could not be decoded to any data`);
80
+ }
81
+ if (buffer.length > MAX_BYTES) {
82
+ throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`);
83
+ }
84
+ return buffer;
85
+ }
86
+
87
+ /**
88
+ * An image upload field that computes width/height, generates a blur placeholder and a
89
+ * thumbnail, and stores files through `context.services.storage`.
90
+ *
91
+ * The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
92
+ * processing happens in a field-level `resolveInput` hook (which receives the request context,
93
+ * unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
94
+ * a configured storage service.
95
+ */
96
+ function computedImage(config = {}) {
97
+ var _config$storage$prefi, _config$storage, _config$thumbnail$wid, _config$thumbnail, _config$blur;
98
+ const prefix = (_config$storage$prefi = (_config$storage = config.storage) === null || _config$storage === void 0 ? void 0 : _config$storage.prefix) !== null && _config$storage$prefi !== void 0 ? _config$storage$prefi : 'images/';
99
+ const thumbnailWidth = config.thumbnail === false ? null : (_config$thumbnail$wid = (_config$thumbnail = config.thumbnail) === null || _config$thumbnail === void 0 ? void 0 : _config$thumbnail.width) !== null && _config$thumbnail$wid !== void 0 ? _config$thumbnail$wid : 320;
100
+ const blurEnabled = (_config$blur = config.blur) !== null && _config$blur !== void 0 ? _config$blur : true;
101
+ return meta => {
102
+ var _config$hooks, _config$hooks2, _config$db;
103
+ if (config.isIndexed === 'unique') {
104
+ throw Error("isIndexed: 'unique' is not a supported option for field type computedImage");
105
+ }
106
+
107
+ /** Remove a stored image (+thumbnail); logged (not thrown) since the DB write already succeeded. */
108
+ async function removeStored(context, stored) {
109
+ var _context$services;
110
+ if (!stored || typeof stored !== 'object') return;
111
+ const storage = context === null || context === void 0 || (_context$services = context.services) === null || _context$services === void 0 ? void 0 : _context$services.storage;
112
+ if (!storage) {
113
+ if (stored.key) {
114
+ console.error(`${PKG} Cannot clean up "${stored.key}": context.services.storage is not configured`);
115
+ }
116
+ return;
117
+ }
118
+ for (const key of [stored.key, stored.thumbnailKey]) {
119
+ if (typeof key !== 'string' || key.length === 0) continue;
120
+ try {
121
+ await storage.delete(key);
122
+ } catch (err) {
123
+ console.error(`${PKG} Failed to delete "${key}" from storage:`, err);
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Decode, measure, derive (thumbnail + blur), store, and return the value to persist. */
129
+ async function processInput(args) {
130
+ var _context$services2, _CONTENT_TYPES$format;
131
+ const {
132
+ resolvedFieldData: value,
133
+ fieldKey,
134
+ context
135
+ } = args;
136
+ if (value === undefined) return undefined;
137
+ if (value === null) return null;
138
+ if (typeof value !== 'object' || Array.isArray(value) || typeof value.base64 !== 'string') {
139
+ throw new Error(`${PKG} ${fieldKey}: expected { base64: string, filename?: string } or null`);
140
+ }
141
+ const input = value;
142
+ const filename = typeof input.filename === 'string' && input.filename.length > 0 ? input.filename : null;
143
+ const storage = context === null || context === void 0 || (_context$services2 = context.services) === null || _context$services2 === void 0 ? void 0 : _context$services2.storage;
144
+ if (!storage) {
145
+ throw new Error(`${PKG} ${fieldKey}: context.services.storage is not configured — a storage service is required`);
146
+ }
147
+ const buffer = decodeBase64(input.base64, fieldKey);
148
+ const sharp = await loadSharp();
149
+ let metadata;
150
+ try {
151
+ metadata = await sharp(buffer).metadata();
152
+ } catch (err) {
153
+ var _message;
154
+ throw new Error(`${PKG} ${fieldKey}: could not read image (unsupported or corrupt): ${(_message = err === null || err === void 0 ? void 0 : err.message) !== null && _message !== void 0 ? _message : err}`);
155
+ }
156
+ const {
157
+ width,
158
+ height,
159
+ format
160
+ } = metadata !== null && metadata !== void 0 ? metadata : {};
161
+ if (typeof width !== 'number' || typeof height !== 'number' || typeof format !== 'string') {
162
+ throw new Error(`${PKG} ${fieldKey}: could not determine image dimensions/format`);
163
+ }
164
+ const id = randomBytes(12).toString('base64url');
165
+ const ext = format === 'jpeg' ? 'jpg' : format;
166
+ const key = `${prefix}${id}.${ext}`;
167
+ const contentType = (_CONTENT_TYPES$format = CONTENT_TYPES[format]) !== null && _CONTENT_TYPES$format !== void 0 ? _CONTENT_TYPES$format : `image/${format}`;
168
+ const stored = await storage.put(key, buffer, {
169
+ contentType,
170
+ public: true
171
+ });
172
+ let thumbnailKey = null;
173
+ let thumbnailUrl = null;
174
+ let blurDataUrl = null;
175
+ try {
176
+ if (thumbnailWidth !== null) {
177
+ const thumbBuffer = await sharp(buffer).resize({
178
+ width: thumbnailWidth,
179
+ withoutEnlargement: true
180
+ }).webp().toBuffer();
181
+ thumbnailKey = `${prefix}${id}.thumb.webp`;
182
+ const storedThumb = await storage.put(thumbnailKey, thumbBuffer, {
183
+ contentType: 'image/webp',
184
+ public: true
185
+ });
186
+ thumbnailUrl = storedThumb.url;
187
+ }
188
+ if (blurEnabled) {
189
+ const blurBuffer = await sharp(buffer).resize(16).webp({
190
+ quality: 40
191
+ }).toBuffer();
192
+ blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`;
193
+ }
194
+ } catch (err) {
195
+ // Don't leave orphaned objects behind when derivative generation fails mid-way.
196
+ await removeStored(context, {
197
+ key,
198
+ thumbnailKey
199
+ });
200
+ throw err;
201
+ }
202
+ return {
203
+ key,
204
+ url: stored.url,
205
+ thumbnailKey,
206
+ thumbnailUrl,
207
+ width,
208
+ height,
209
+ filesize: buffer.length,
210
+ format,
211
+ blurDataUrl,
212
+ filename
213
+ };
214
+ }
215
+
216
+ // Compose our processing/cleanup with any hooks the user configured: our resolveInput runs
217
+ // first (turning the raw { base64 } input into the stored JSON), then the user's; the
218
+ // user's afterOperation runs first, then our storage cleanup (mirroring @nixxie-cms/cloudinary).
219
+ const userResolveInput = (_config$hooks = config.hooks) === null || _config$hooks === void 0 ? void 0 : _config$hooks.resolveInput;
220
+ const userAfterOperation = (_config$hooks2 = config.hooks) === null || _config$hooks2 === void 0 ? void 0 : _config$hooks2.afterOperation;
221
+ const hooks = {
222
+ ...config.hooks,
223
+ resolveInput: async args => {
224
+ const processed = await processInput(args);
225
+ const userHook = typeof userResolveInput === 'function' ? userResolveInput : userResolveInput === null || userResolveInput === void 0 ? void 0 : userResolveInput[args.operation];
226
+ if (!userHook) return processed;
227
+ return userHook({
228
+ ...args,
229
+ resolvedFieldData: processed,
230
+ resolvedData: {
231
+ ...args.resolvedData,
232
+ [args.fieldKey]: processed
233
+ }
234
+ });
235
+ },
236
+ afterOperation: async args => {
237
+ const userHook = typeof userAfterOperation === 'function' ? userAfterOperation : userAfterOperation === null || userAfterOperation === void 0 ? void 0 : userAfterOperation[args.operation];
238
+ await (userHook === null || userHook === void 0 ? void 0 : userHook(args));
239
+ if (args.operation === 'delete') {
240
+ await removeStored(args.context, args.originalItemField);
241
+ } else if (args.operation === 'update') {
242
+ const previous = args.originalItemField;
243
+ const next = args.itemField;
244
+ if (previous !== null && previous !== void 0 && previous.key && previous.key !== (next === null || next === void 0 ? void 0 : next.key)) {
245
+ await removeStored(args.context, previous);
246
+ }
247
+ }
248
+ }
249
+ };
250
+ const inputArg = g.arg({
251
+ type: g.JSON
252
+ });
253
+ return fieldType({
254
+ kind: 'scalar',
255
+ mode: 'optional',
256
+ scalar: 'Json',
257
+ map: (_config$db = config.db) === null || _config$db === void 0 ? void 0 : _config$db.map
258
+ })({
259
+ ...config,
260
+ hooks,
261
+ __nxTelemetryFieldTypeName: '@nixxie-cms/fields-computed-image',
262
+ input: {
263
+ // The raw GraphQL input resolver does not receive context, so the value passes through
264
+ // here untouched; all storage work happens in the field-level resolveInput hook above.
265
+ create: {
266
+ arg: inputArg
267
+ },
268
+ update: {
269
+ arg: inputArg
270
+ }
271
+ },
272
+ output: g.field({
273
+ type: outputType,
274
+ resolve({
275
+ value
276
+ }) {
277
+ if (value === null || typeof value !== 'object') return null;
278
+ const val = value;
279
+ if (typeof val.key !== 'string') return null;
280
+ return {
281
+ thumbnailKey: null,
282
+ thumbnailUrl: null,
283
+ blurDataUrl: null,
284
+ filename: null,
285
+ ...val
286
+ };
287
+ }
288
+ }),
289
+ views: '@nixxie-cms/core/fields/types/json/views'
290
+ });
291
+ };
292
+ }
293
+
294
+ export { computedImage };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@nixxie-cms/fields-computed-image",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "main": "dist/nixxie-cms-fields-computed-image.cjs.js",
6
+ "module": "dist/nixxie-cms-fields-computed-image.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/nixxie-cms-fields-computed-image.cjs.js",
10
+ "module": "./dist/nixxie-cms-fields-computed-image.esm.js",
11
+ "default": "./dist/nixxie-cms-fields-computed-image.cjs.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/runtime": "^7.24.7"
17
+ },
18
+ "devDependencies": {
19
+ "sharp": "^0.33.0",
20
+ "@nixxie-cms/core": "^1.1.0"
21
+ },
22
+ "peerDependencies": {
23
+ "@nixxie-cms/core": "^1.0.3",
24
+ "sharp": "^0.33.0"
25
+ },
26
+ "preconstruct": {
27
+ "entrypoints": [
28
+ "index.ts"
29
+ ]
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-computed-image"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,304 @@
1
+ import { g } from '@nixxie-cms/core'
2
+ import type {
3
+ BaseFieldTypeInfo,
4
+ BaseCollectionTypeInfo,
5
+ CommonFieldConfig,
6
+ FieldTypeFunc,
7
+ } from '@nixxie-cms/core/types'
8
+ import { fieldType } from '@nixxie-cms/core/types'
9
+ import { randomBytes } from 'node:crypto'
10
+
11
+ const PKG = '[@nixxie-cms/fields-computed-image]'
12
+ const MAX_BYTES = 50 * 1024 * 1024 // ~50MB decoded
13
+
14
+ /** The GraphQL input shape — a base64-encoded image (data URLs accepted), or null to clear. */
15
+ export type ComputedImageInput = {
16
+ base64: string
17
+ filename?: string
18
+ } | null
19
+
20
+ /** The JSON value stored in the database (and returned from the output type). */
21
+ export type ComputedImageData = {
22
+ /** Storage key of the original image. */
23
+ key: string
24
+ /** URL of the original image. */
25
+ url: string
26
+ /** Storage key of the generated thumbnail (null when thumbnails are disabled). */
27
+ thumbnailKey: string | null
28
+ /** URL of the generated thumbnail. */
29
+ thumbnailUrl: string | null
30
+ width: number
31
+ height: number
32
+ /** Original file size in bytes. */
33
+ filesize: number
34
+ /** Image format as detected by sharp, e.g. 'jpeg', 'png', 'webp'. */
35
+ format: string
36
+ /** Tiny inline blur placeholder (`data:image/webp;base64,…`), null when disabled. */
37
+ blurDataUrl: string | null
38
+ /** Client-supplied filename, if any. */
39
+ filename: string | null
40
+ }
41
+
42
+ export type ComputedImageFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> =
43
+ CommonFieldConfig<CollectionTypeInfo, BaseFieldTypeInfo> & {
44
+ storage?: {
45
+ /** Key prefix for stored objects. Default: 'images/' */
46
+ prefix?: string
47
+ }
48
+ /** Thumbnail generation options, or `false` to disable. Default: { width: 320 } */
49
+ thumbnail?: { width?: number } | false
50
+ /** Generate a tiny inline blur placeholder. Default: true */
51
+ blur?: boolean
52
+ db?: { map?: string }
53
+ }
54
+
55
+ const CONTENT_TYPES: Record<string, string> = {
56
+ jpeg: 'image/jpeg',
57
+ png: 'image/png',
58
+ webp: 'image/webp',
59
+ gif: 'image/gif',
60
+ avif: 'image/avif',
61
+ tiff: 'image/tiff',
62
+ svg: 'image/svg+xml',
63
+ heif: 'image/heif',
64
+ }
65
+
66
+ const outputType = g.object<ComputedImageData>()({
67
+ name: 'ComputedImage',
68
+ fields: {
69
+ key: g.field({ type: g.nonNull(g.String) }),
70
+ url: g.field({ type: g.nonNull(g.String) }),
71
+ thumbnailKey: g.field({ type: g.String }),
72
+ thumbnailUrl: g.field({ type: g.String }),
73
+ width: g.field({ type: g.nonNull(g.Int) }),
74
+ height: g.field({ type: g.nonNull(g.Int) }),
75
+ filesize: g.field({ type: g.nonNull(g.Int) }),
76
+ format: g.field({ type: g.nonNull(g.String) }),
77
+ blurDataUrl: g.field({ type: g.String }),
78
+ filename: g.field({ type: g.String }),
79
+ },
80
+ })
81
+
82
+ async function loadSharp(): Promise<any> {
83
+ try {
84
+ const mod: any = await import('sharp')
85
+ return mod.default ?? mod
86
+ } catch {
87
+ throw new Error(`${PKG} sharp is required — install it with: pnpm add sharp`)
88
+ }
89
+ }
90
+
91
+ /** Decode a base64 string (optionally a data URL), enforcing the decoded size cap. */
92
+ function decodeBase64(input: string, fieldKey: string): Buffer {
93
+ let base64 = input.trim()
94
+ const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(base64)
95
+ if (dataUrlMatch) base64 = dataUrlMatch[1]
96
+ base64 = base64.replace(/\s+/g, '')
97
+ // Pre-check before allocating: decoded size is ~3/4 of the base64 length.
98
+ if (base64.length * 0.75 > MAX_BYTES) {
99
+ throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`)
100
+ }
101
+ const buffer = Buffer.from(base64, 'base64')
102
+ if (buffer.length === 0) {
103
+ throw new Error(`${PKG} ${fieldKey}: base64 could not be decoded to any data`)
104
+ }
105
+ if (buffer.length > MAX_BYTES) {
106
+ throw new Error(`${PKG} ${fieldKey}: image exceeds the ${MAX_BYTES / (1024 * 1024)}MB limit`)
107
+ }
108
+ return buffer
109
+ }
110
+
111
+ /**
112
+ * An image upload field that computes width/height, generates a blur placeholder and a
113
+ * thumbnail, and stores files through `context.services.storage`.
114
+ *
115
+ * The GraphQL input is plain JSON — `{ base64, filename? }` or `null` to clear — and all
116
+ * processing happens in a field-level `resolveInput` hook (which receives the request context,
117
+ * unlike the field type's raw GraphQL input resolver). Requires the `sharp` peer dependency and
118
+ * a configured storage service.
119
+ */
120
+ export function computedImage<CollectionTypeInfo extends BaseCollectionTypeInfo>(
121
+ config: ComputedImageFieldConfig<CollectionTypeInfo> = {}
122
+ ): FieldTypeFunc<CollectionTypeInfo> {
123
+ const prefix = config.storage?.prefix ?? 'images/'
124
+ const thumbnailWidth = config.thumbnail === false ? null : (config.thumbnail?.width ?? 320)
125
+ const blurEnabled = config.blur ?? true
126
+
127
+ return meta => {
128
+ if ((config as any).isIndexed === 'unique') {
129
+ throw Error("isIndexed: 'unique' is not a supported option for field type computedImage")
130
+ }
131
+
132
+ /** Remove a stored image (+thumbnail); logged (not thrown) since the DB write already succeeded. */
133
+ async function removeStored(context: any, stored: Partial<ComputedImageData> | null | undefined) {
134
+ if (!stored || typeof stored !== 'object') return
135
+ const storage = context?.services?.storage
136
+ if (!storage) {
137
+ if (stored.key) {
138
+ console.error(`${PKG} Cannot clean up "${stored.key}": context.services.storage is not configured`)
139
+ }
140
+ return
141
+ }
142
+ for (const key of [stored.key, stored.thumbnailKey]) {
143
+ if (typeof key !== 'string' || key.length === 0) continue
144
+ try {
145
+ await storage.delete(key)
146
+ } catch (err) {
147
+ console.error(`${PKG} Failed to delete "${key}" from storage:`, err)
148
+ }
149
+ }
150
+ }
151
+
152
+ /** Decode, measure, derive (thumbnail + blur), store, and return the value to persist. */
153
+ async function processInput(args: any): Promise<ComputedImageData | null | undefined> {
154
+ const { resolvedFieldData: value, fieldKey, context } = args
155
+ if (value === undefined) return undefined
156
+ if (value === null) return null
157
+
158
+ if (typeof value !== 'object' || Array.isArray(value) || typeof (value as any).base64 !== 'string') {
159
+ throw new Error(`${PKG} ${fieldKey}: expected { base64: string, filename?: string } or null`)
160
+ }
161
+ const input = value as { base64: string; filename?: unknown }
162
+ const filename = typeof input.filename === 'string' && input.filename.length > 0 ? input.filename : null
163
+
164
+ const storage = context?.services?.storage
165
+ if (!storage) {
166
+ throw new Error(`${PKG} ${fieldKey}: context.services.storage is not configured — a storage service is required`)
167
+ }
168
+
169
+ const buffer = decodeBase64(input.base64, fieldKey)
170
+ const sharp = await loadSharp()
171
+
172
+ let metadata: any
173
+ try {
174
+ metadata = await sharp(buffer).metadata()
175
+ } catch (err) {
176
+ throw new Error(`${PKG} ${fieldKey}: could not read image (unsupported or corrupt): ${(err as Error)?.message ?? err}`)
177
+ }
178
+ const { width, height, format } = metadata ?? {}
179
+ if (typeof width !== 'number' || typeof height !== 'number' || typeof format !== 'string') {
180
+ throw new Error(`${PKG} ${fieldKey}: could not determine image dimensions/format`)
181
+ }
182
+
183
+ const id = randomBytes(12).toString('base64url')
184
+ const ext = format === 'jpeg' ? 'jpg' : format
185
+ const key = `${prefix}${id}.${ext}`
186
+ const contentType = CONTENT_TYPES[format] ?? `image/${format}`
187
+
188
+ const stored = await storage.put(key, buffer, { contentType, public: true })
189
+
190
+ let thumbnailKey: string | null = null
191
+ let thumbnailUrl: string | null = null
192
+ let blurDataUrl: string | null = null
193
+ try {
194
+ if (thumbnailWidth !== null) {
195
+ const thumbBuffer = await sharp(buffer)
196
+ .resize({ width: thumbnailWidth, withoutEnlargement: true })
197
+ .webp()
198
+ .toBuffer()
199
+ thumbnailKey = `${prefix}${id}.thumb.webp`
200
+ const storedThumb = await storage.put(thumbnailKey, thumbBuffer, {
201
+ contentType: 'image/webp',
202
+ public: true,
203
+ })
204
+ thumbnailUrl = storedThumb.url
205
+ }
206
+ if (blurEnabled) {
207
+ const blurBuffer = await sharp(buffer).resize(16).webp({ quality: 40 }).toBuffer()
208
+ blurDataUrl = `data:image/webp;base64,${blurBuffer.toString('base64')}`
209
+ }
210
+ } catch (err) {
211
+ // Don't leave orphaned objects behind when derivative generation fails mid-way.
212
+ await removeStored(context, { key, thumbnailKey })
213
+ throw err
214
+ }
215
+
216
+ return {
217
+ key,
218
+ url: stored.url,
219
+ thumbnailKey,
220
+ thumbnailUrl,
221
+ width,
222
+ height,
223
+ filesize: buffer.length,
224
+ format,
225
+ blurDataUrl,
226
+ filename,
227
+ }
228
+ }
229
+
230
+ // Compose our processing/cleanup with any hooks the user configured: our resolveInput runs
231
+ // first (turning the raw { base64 } input into the stored JSON), then the user's; the
232
+ // user's afterOperation runs first, then our storage cleanup (mirroring @nixxie-cms/cloudinary).
233
+ const userResolveInput = config.hooks?.resolveInput
234
+ const userAfterOperation = config.hooks?.afterOperation
235
+ const hooks: typeof config.hooks = {
236
+ ...config.hooks,
237
+ resolveInput: async (args: any) => {
238
+ const processed = await processInput(args)
239
+ const userHook =
240
+ typeof userResolveInput === 'function'
241
+ ? userResolveInput
242
+ : (userResolveInput as any)?.[args.operation]
243
+ if (!userHook) return processed
244
+ return userHook({
245
+ ...args,
246
+ resolvedFieldData: processed,
247
+ resolvedData: { ...args.resolvedData, [args.fieldKey]: processed },
248
+ })
249
+ },
250
+ afterOperation: async (args: any) => {
251
+ const userHook =
252
+ typeof userAfterOperation === 'function'
253
+ ? userAfterOperation
254
+ : (userAfterOperation as any)?.[args.operation]
255
+ await (userHook as any)?.(args)
256
+
257
+ if (args.operation === 'delete') {
258
+ await removeStored(args.context, args.originalItemField as ComputedImageData | null)
259
+ } else if (args.operation === 'update') {
260
+ const previous = args.originalItemField as ComputedImageData | null
261
+ const next = args.itemField as ComputedImageData | null
262
+ if (previous?.key && previous.key !== next?.key) {
263
+ await removeStored(args.context, previous)
264
+ }
265
+ }
266
+ },
267
+ }
268
+
269
+ const inputArg = g.arg({ type: g.JSON })
270
+
271
+ return fieldType({
272
+ kind: 'scalar',
273
+ mode: 'optional',
274
+ scalar: 'Json',
275
+ map: config.db?.map,
276
+ })({
277
+ ...config,
278
+ hooks,
279
+ __nxTelemetryFieldTypeName: '@nixxie-cms/fields-computed-image',
280
+ input: {
281
+ // The raw GraphQL input resolver does not receive context, so the value passes through
282
+ // here untouched; all storage work happens in the field-level resolveInput hook above.
283
+ create: { arg: inputArg },
284
+ update: { arg: inputArg },
285
+ },
286
+ output: g.field({
287
+ type: outputType,
288
+ resolve({ value }) {
289
+ if (value === null || typeof value !== 'object') return null
290
+ const val = value as any
291
+ if (typeof val.key !== 'string') return null
292
+ return {
293
+ thumbnailKey: null,
294
+ thumbnailUrl: null,
295
+ blurDataUrl: null,
296
+ filename: null,
297
+ ...val,
298
+ }
299
+ },
300
+ }),
301
+ views: '@nixxie-cms/core/fields/types/json/views',
302
+ })
303
+ }
304
+ }