@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.
Files changed (3) hide show
  1. package/README.md +22 -2
  2. package/package.json +1 -1
  3. 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
- - 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.2",
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
 
@@ -186,12 +226,47 @@ export function createImageHandler(
186
226
  }
187
227
  }
188
228
 
189
- const params = parseParams(event.queryStringParameters || {});
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
- // Fetch original from S3 for processing
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
- 'Cache-Control': config.cacheControl ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5',
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
+ }