@everystack/server 0.2.1 → 0.2.2
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 +142 -5
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
|
|
|
@@ -186,12 +226,47 @@ export function createImageHandler(
|
|
|
186
226
|
}
|
|
187
227
|
}
|
|
188
228
|
|
|
189
|
-
const
|
|
229
|
+
const queryParams = event.queryStringParameters || {};
|
|
230
|
+
const params = parseParams(queryParams);
|
|
190
231
|
|
|
191
|
-
const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
|
232
|
+
const { S3Client, GetObjectCommand, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
192
233
|
const client = new S3Client({ region });
|
|
193
234
|
|
|
194
|
-
|
|
235
|
+
const cacheHeaders = {
|
|
236
|
+
'Cache-Control': config.cacheControl ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5',
|
|
237
|
+
'Vary': 'Accept',
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// --- S3 origin cache: check for existing render ---
|
|
241
|
+
const useCache = config.s3Cache?.enabled === true;
|
|
242
|
+
const cachePrefix = config.s3Cache?.prefix ?? 'cache/';
|
|
243
|
+
let s3CacheKey: string | undefined;
|
|
244
|
+
|
|
245
|
+
if (useCache) {
|
|
246
|
+
s3CacheKey = computeCacheKey(key, params, cachePrefix, queryParams._v);
|
|
247
|
+
try {
|
|
248
|
+
const cached = await client.send(
|
|
249
|
+
new GetObjectCommand({ Bucket: bucket, Key: s3CacheKey })
|
|
250
|
+
);
|
|
251
|
+
if (cached.Body) {
|
|
252
|
+
const bytes = await cached.Body.transformToByteArray();
|
|
253
|
+
return {
|
|
254
|
+
statusCode: 200,
|
|
255
|
+
headers: {
|
|
256
|
+
'Content-Type': cached.ContentType || FORMAT_CONTENT_TYPES[params.fm || 'webp'],
|
|
257
|
+
...cacheHeaders,
|
|
258
|
+
},
|
|
259
|
+
body: Buffer.from(bytes).toString('base64'),
|
|
260
|
+
isBase64Encoded: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
} catch (error: any) {
|
|
264
|
+
// Cache miss — fall through to process
|
|
265
|
+
if (!isNotFound(error)) throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Fetch original from S3 for processing ---
|
|
195
270
|
let originalData: Buffer;
|
|
196
271
|
try {
|
|
197
272
|
const result = await client.send(
|
|
@@ -220,12 +295,28 @@ export function createImageHandler(
|
|
|
220
295
|
// Process with Sharp
|
|
221
296
|
const processed = await processImage(originalData, params);
|
|
222
297
|
|
|
298
|
+
// --- Write rendered image to S3 cache (fire-and-forget) ---
|
|
299
|
+
if (useCache && s3CacheKey) {
|
|
300
|
+
client
|
|
301
|
+
.send(
|
|
302
|
+
new PutObjectCommand({
|
|
303
|
+
Bucket: bucket,
|
|
304
|
+
Key: s3CacheKey,
|
|
305
|
+
Body: processed.data,
|
|
306
|
+
ContentType: processed.contentType,
|
|
307
|
+
CacheControl: 'public, max-age=2592000',
|
|
308
|
+
})
|
|
309
|
+
)
|
|
310
|
+
.catch((err: unknown) => {
|
|
311
|
+
log('warn', 'S3 cache write failed', { key: s3CacheKey, error: String(err) });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
223
315
|
return {
|
|
224
316
|
statusCode: 200,
|
|
225
317
|
headers: {
|
|
226
318
|
'Content-Type': processed.contentType,
|
|
227
|
-
|
|
228
|
-
'Vary': 'Accept',
|
|
319
|
+
...cacheHeaders,
|
|
229
320
|
},
|
|
230
321
|
body: processed.data.toString('base64'),
|
|
231
322
|
isBase64Encoded: true,
|
|
@@ -240,3 +331,49 @@ export function createImageHandler(
|
|
|
240
331
|
}
|
|
241
332
|
};
|
|
242
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Delete all cached renders for an image key.
|
|
337
|
+
* Call this when the original image is deleted or replaced.
|
|
338
|
+
*
|
|
339
|
+
* Returns the number of cached objects deleted.
|
|
340
|
+
*/
|
|
341
|
+
export async function deleteImageCache(
|
|
342
|
+
bucket: string,
|
|
343
|
+
key: string,
|
|
344
|
+
options?: { region?: string; prefix?: string }
|
|
345
|
+
): Promise<number> {
|
|
346
|
+
const { S3Client, ListObjectsV2Command, DeleteObjectsCommand } = await import('@aws-sdk/client-s3');
|
|
347
|
+
const client = new S3Client({ region: options?.region || process.env.AWS_REGION });
|
|
348
|
+
const cachePrefix = (options?.prefix ?? 'cache/') + key + '/';
|
|
349
|
+
|
|
350
|
+
let deleted = 0;
|
|
351
|
+
let continuationToken: string | undefined;
|
|
352
|
+
|
|
353
|
+
do {
|
|
354
|
+
const list = await client.send(
|
|
355
|
+
new ListObjectsV2Command({
|
|
356
|
+
Bucket: bucket,
|
|
357
|
+
Prefix: cachePrefix,
|
|
358
|
+
ContinuationToken: continuationToken,
|
|
359
|
+
})
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (list.Contents?.length) {
|
|
363
|
+
await client.send(
|
|
364
|
+
new DeleteObjectsCommand({
|
|
365
|
+
Bucket: bucket,
|
|
366
|
+
Delete: {
|
|
367
|
+
Objects: list.Contents.map((obj) => ({ Key: obj.Key })),
|
|
368
|
+
Quiet: true,
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
);
|
|
372
|
+
deleted += list.Contents.length;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
continuationToken = list.NextContinuationToken;
|
|
376
|
+
} while (continuationToken);
|
|
377
|
+
|
|
378
|
+
return deleted;
|
|
379
|
+
}
|