@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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/package.json +1 -1
  3. 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
- - No transforms 302 redirect to presigned S3 URL (avoids 6MB Lambda limit)
166
- - With transforms fetch from S3, process with Sharp, return with immutable cache headers
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
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 && VALID_FITS.includes(query.fit as any)) {
68
- params.fit = query.fit as TransformParams['fit'];
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: params.fit || 'cover',
118
- withoutEnlargement: true,
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 params = parseParams(event.queryStringParameters || {});
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
- // Fetch original from S3 for processing
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 = await processImage(originalData, params);
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
- 'Cache-Control': config.cacheControl ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5',
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
+ }