@everystack/server 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/package.json +1 -1
- package/src/image.ts +173 -10
package/README.md
CHANGED
|
@@ -156,14 +156,34 @@ interface ImageHandlerConfig {
|
|
|
156
156
|
bucket: string; // S3 bucket name
|
|
157
157
|
region?: string; // AWS region (default: AWS_REGION env)
|
|
158
158
|
pathPrefix?: string; // URL prefix (default: '/media/')
|
|
159
|
+
cacheControl?: string; // Cache-Control header (default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5')
|
|
160
|
+
validateKey?: (key: string) => boolean | Promise<boolean>; // Optional key validator
|
|
161
|
+
s3Cache?: { // Persist rendered images to S3 (skip Sharp on repeat requests)
|
|
162
|
+
enabled: boolean;
|
|
163
|
+
prefix?: string; // S3 key prefix (default: 'cache/')
|
|
164
|
+
};
|
|
159
165
|
}
|
|
160
166
|
```
|
|
161
167
|
|
|
162
168
|
URL format: `/media/{key}?w=400&h=300&fit=cover&fm=webp&q=80&dpr=2`
|
|
163
169
|
|
|
164
170
|
**Behavior:**
|
|
165
|
-
-
|
|
166
|
-
-
|
|
171
|
+
- Fetches original from S3, processes with Sharp, returns with cache headers
|
|
172
|
+
- All images get EXIF auto-rotation and format conversion (default: webp q80)
|
|
173
|
+
- With `s3Cache` enabled: checks S3 for a cached render before processing, writes result to S3 after first render. Subsequent requests from any CloudFront region skip Sharp entirely.
|
|
174
|
+
|
|
175
|
+
### `deleteImageCache(bucket, key, options?)`
|
|
176
|
+
|
|
177
|
+
Deletes all cached renders for an image key. Call when the original is deleted or replaced.
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import { deleteImageCache } from '@everystack/server/image';
|
|
181
|
+
|
|
182
|
+
const deleted = await deleteImageCache('my-bucket', 'uploads/photo.jpg');
|
|
183
|
+
// deleted = number of cached objects removed
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Options: `{ region?: string; prefix?: string }` (prefix defaults to `'cache/'`).
|
|
167
187
|
|
|
168
188
|
### `parseParams(query)`
|
|
169
189
|
|
package/package.json
CHANGED
package/src/image.ts
CHANGED
|
@@ -16,6 +16,13 @@ import type {
|
|
|
16
16
|
} from 'aws-lambda';
|
|
17
17
|
import { log } from './index.js';
|
|
18
18
|
|
|
19
|
+
export interface S3CacheConfig {
|
|
20
|
+
/** Enable S3 origin cache for processed images. Default: false. */
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
/** S3 key prefix for cached renders. Default: 'cache/' */
|
|
23
|
+
prefix?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
export interface ImageHandlerConfig {
|
|
20
27
|
/** S3 bucket name for media storage */
|
|
21
28
|
bucket: string;
|
|
@@ -32,6 +39,12 @@ export interface ImageHandlerConfig {
|
|
|
32
39
|
* Use this to verify the key exists in your database or matches an allowed prefix.
|
|
33
40
|
*/
|
|
34
41
|
validateKey?: (key: string) => boolean | Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* Persist processed images to S3 so subsequent requests from any
|
|
44
|
+
* CloudFront region skip Sharp entirely. Cached renders auto-expire
|
|
45
|
+
* via S3 lifecycle rules (configure on the bucket, not here).
|
|
46
|
+
*/
|
|
47
|
+
s3Cache?: S3CacheConfig;
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
export interface TransformParams {
|
|
@@ -53,6 +66,33 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
|
|
53
66
|
};
|
|
54
67
|
const MAX_DIMENSION = 4096;
|
|
55
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Build a deterministic S3 key from parsed params (post-DPR).
|
|
71
|
+
* Includes the _v epoch when present so cache versioning invalidates correctly.
|
|
72
|
+
*
|
|
73
|
+
* Examples:
|
|
74
|
+
* computeCacheKey('uploads/photo.jpg', {w:400, fm:'webp'}, 'cache/', '1778646364')
|
|
75
|
+
* → 'cache/uploads/photo.jpg/v1778646364/w400_webp_q80.webp'
|
|
76
|
+
*/
|
|
77
|
+
export function computeCacheKey(
|
|
78
|
+
key: string,
|
|
79
|
+
params: TransformParams,
|
|
80
|
+
prefix: string,
|
|
81
|
+
version?: string
|
|
82
|
+
): string {
|
|
83
|
+
const segments: string[] = [];
|
|
84
|
+
if (params.w) segments.push(`w${params.w}`);
|
|
85
|
+
if (params.h) segments.push(`h${params.h}`);
|
|
86
|
+
if (params.fit) segments.push(params.fit);
|
|
87
|
+
const format = params.fm || 'webp';
|
|
88
|
+
segments.push(format);
|
|
89
|
+
segments.push(`q${params.q || 80}`);
|
|
90
|
+
|
|
91
|
+
const variant = segments.join('_');
|
|
92
|
+
const versionSegment = version ? `v${version}/` : '';
|
|
93
|
+
return `${prefix}${key}/${versionSegment}${variant}.${format}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
56
96
|
export function parseParams(query: Record<string, string | undefined>): TransformParams {
|
|
57
97
|
const params: TransformParams = {};
|
|
58
98
|
|
|
@@ -64,8 +104,11 @@ export function parseParams(query: Record<string, string | undefined>): Transfor
|
|
|
64
104
|
const h = parseInt(query.h, 10);
|
|
65
105
|
if (h > 0 && h <= MAX_DIMENSION) params.h = h;
|
|
66
106
|
}
|
|
67
|
-
if (query.fit
|
|
68
|
-
|
|
107
|
+
if (query.fit) {
|
|
108
|
+
const fit = query.fit === 'crop' ? 'cover' : query.fit;
|
|
109
|
+
if (VALID_FITS.includes(fit as any)) {
|
|
110
|
+
params.fit = fit as TransformParams['fit'];
|
|
111
|
+
}
|
|
69
112
|
}
|
|
70
113
|
if (query.fm && VALID_FORMATS.includes(query.fm as any)) {
|
|
71
114
|
params.fm = query.fm as TransformParams['fm'];
|
|
@@ -111,11 +154,12 @@ export async function processImage(
|
|
|
111
154
|
|
|
112
155
|
// Resize
|
|
113
156
|
if (params.w || params.h) {
|
|
157
|
+
const fit = params.fit || 'cover';
|
|
114
158
|
pipeline = pipeline.resize({
|
|
115
159
|
width: params.w,
|
|
116
160
|
height: params.h,
|
|
117
|
-
fit
|
|
118
|
-
withoutEnlargement:
|
|
161
|
+
fit,
|
|
162
|
+
withoutEnlargement: fit !== 'cover' && fit !== 'fill',
|
|
119
163
|
});
|
|
120
164
|
}
|
|
121
165
|
|
|
@@ -186,13 +230,49 @@ export function createImageHandler(
|
|
|
186
230
|
}
|
|
187
231
|
}
|
|
188
232
|
|
|
189
|
-
const
|
|
233
|
+
const queryParams = event.queryStringParameters || {};
|
|
234
|
+
const params = parseParams(queryParams);
|
|
190
235
|
|
|
191
|
-
const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
236
|
+
const { S3Client, GetObjectCommand, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
192
237
|
const client = new S3Client({ region });
|
|
193
238
|
|
|
194
|
-
|
|
239
|
+
const cacheHeaders = {
|
|
240
|
+
'Cache-Control': config.cacheControl ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5',
|
|
241
|
+
'Vary': 'Accept',
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// --- S3 origin cache: check for existing render ---
|
|
245
|
+
const useCache = config.s3Cache?.enabled === true;
|
|
246
|
+
const cachePrefix = config.s3Cache?.prefix ?? 'cache/';
|
|
247
|
+
let s3CacheKey: string | undefined;
|
|
248
|
+
|
|
249
|
+
if (useCache) {
|
|
250
|
+
s3CacheKey = computeCacheKey(key, params, cachePrefix, queryParams._v);
|
|
251
|
+
try {
|
|
252
|
+
const cached = await client.send(
|
|
253
|
+
new GetObjectCommand({ Bucket: bucket, Key: s3CacheKey })
|
|
254
|
+
);
|
|
255
|
+
if (cached.Body) {
|
|
256
|
+
const bytes = await cached.Body.transformToByteArray();
|
|
257
|
+
return {
|
|
258
|
+
statusCode: 200,
|
|
259
|
+
headers: {
|
|
260
|
+
'Content-Type': cached.ContentType || FORMAT_CONTENT_TYPES[params.fm || 'webp'],
|
|
261
|
+
...cacheHeaders,
|
|
262
|
+
},
|
|
263
|
+
body: Buffer.from(bytes).toString('base64'),
|
|
264
|
+
isBase64Encoded: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
} catch (error: any) {
|
|
268
|
+
// Cache miss — fall through to process
|
|
269
|
+
if (!isNotFound(error)) throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Fetch original from S3 for processing ---
|
|
195
274
|
let originalData: Buffer;
|
|
275
|
+
let originalContentType: string | undefined;
|
|
196
276
|
try {
|
|
197
277
|
const result = await client.send(
|
|
198
278
|
new GetObjectCommand({ Bucket: bucket, Key: key })
|
|
@@ -204,6 +284,7 @@ export function createImageHandler(
|
|
|
204
284
|
body: JSON.stringify({ error: 'Image not found' }),
|
|
205
285
|
};
|
|
206
286
|
}
|
|
287
|
+
originalContentType = result.ContentType || undefined;
|
|
207
288
|
const bytes = await result.Body.transformToByteArray();
|
|
208
289
|
originalData = Buffer.from(bytes);
|
|
209
290
|
} catch (error: any) {
|
|
@@ -217,15 +298,51 @@ export function createImageHandler(
|
|
|
217
298
|
throw error;
|
|
218
299
|
}
|
|
219
300
|
|
|
301
|
+
// Pass through non-image files (fonts, CSS, etc.) without processing
|
|
302
|
+
const isImage = originalContentType?.startsWith('image/') ??
|
|
303
|
+
/\.(jpe?g|png|webp|avif|gif|tiff?|svg)$/i.test(key);
|
|
304
|
+
if (!isImage) {
|
|
305
|
+
return {
|
|
306
|
+
statusCode: 200,
|
|
307
|
+
headers: {
|
|
308
|
+
'Content-Type': originalContentType || 'application/octet-stream',
|
|
309
|
+
...cacheHeaders,
|
|
310
|
+
},
|
|
311
|
+
body: originalData.toString('base64'),
|
|
312
|
+
isBase64Encoded: true,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// No transform params — serve original image as-is (EXIF-corrected webp)
|
|
317
|
+
const hasTransforms = params.w || params.h || params.fit || params.fm || params.q;
|
|
318
|
+
|
|
220
319
|
// Process with Sharp
|
|
221
|
-
const processed =
|
|
320
|
+
const processed = hasTransforms
|
|
321
|
+
? await processImage(originalData, params)
|
|
322
|
+
: await processImage(originalData, { fm: 'webp', q: 80 });
|
|
323
|
+
|
|
324
|
+
// --- Write rendered image to S3 cache (fire-and-forget) ---
|
|
325
|
+
if (useCache && s3CacheKey) {
|
|
326
|
+
client
|
|
327
|
+
.send(
|
|
328
|
+
new PutObjectCommand({
|
|
329
|
+
Bucket: bucket,
|
|
330
|
+
Key: s3CacheKey,
|
|
331
|
+
Body: processed.data,
|
|
332
|
+
ContentType: processed.contentType,
|
|
333
|
+
CacheControl: 'public, max-age=2592000',
|
|
334
|
+
})
|
|
335
|
+
)
|
|
336
|
+
.catch((err: unknown) => {
|
|
337
|
+
log('warn', 'S3 cache write failed', { key: s3CacheKey, error: String(err) });
|
|
338
|
+
});
|
|
339
|
+
}
|
|
222
340
|
|
|
223
341
|
return {
|
|
224
342
|
statusCode: 200,
|
|
225
343
|
headers: {
|
|
226
344
|
'Content-Type': processed.contentType,
|
|
227
|
-
|
|
228
|
-
'Vary': 'Accept',
|
|
345
|
+
...cacheHeaders,
|
|
229
346
|
},
|
|
230
347
|
body: processed.data.toString('base64'),
|
|
231
348
|
isBase64Encoded: true,
|
|
@@ -240,3 +357,49 @@ export function createImageHandler(
|
|
|
240
357
|
}
|
|
241
358
|
};
|
|
242
359
|
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Delete all cached renders for an image key.
|
|
363
|
+
* Call this when the original image is deleted or replaced.
|
|
364
|
+
*
|
|
365
|
+
* Returns the number of cached objects deleted.
|
|
366
|
+
*/
|
|
367
|
+
export async function deleteImageCache(
|
|
368
|
+
bucket: string,
|
|
369
|
+
key: string,
|
|
370
|
+
options?: { region?: string; prefix?: string }
|
|
371
|
+
): Promise<number> {
|
|
372
|
+
const { S3Client, ListObjectsV2Command, DeleteObjectsCommand } = await import('@aws-sdk/client-s3');
|
|
373
|
+
const client = new S3Client({ region: options?.region || process.env.AWS_REGION });
|
|
374
|
+
const cachePrefix = (options?.prefix ?? 'cache/') + key + '/';
|
|
375
|
+
|
|
376
|
+
let deleted = 0;
|
|
377
|
+
let continuationToken: string | undefined;
|
|
378
|
+
|
|
379
|
+
do {
|
|
380
|
+
const list = await client.send(
|
|
381
|
+
new ListObjectsV2Command({
|
|
382
|
+
Bucket: bucket,
|
|
383
|
+
Prefix: cachePrefix,
|
|
384
|
+
ContinuationToken: continuationToken,
|
|
385
|
+
})
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if (list.Contents?.length) {
|
|
389
|
+
await client.send(
|
|
390
|
+
new DeleteObjectsCommand({
|
|
391
|
+
Bucket: bucket,
|
|
392
|
+
Delete: {
|
|
393
|
+
Objects: list.Contents.map((obj) => ({ Key: obj.Key })),
|
|
394
|
+
Quiet: true,
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
);
|
|
398
|
+
deleted += list.Contents.length;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
continuationToken = list.NextContinuationToken;
|
|
402
|
+
} while (continuationToken);
|
|
403
|
+
|
|
404
|
+
return deleted;
|
|
405
|
+
}
|